mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 14:23:19 +08:00
feat: native端docker支持
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:serif="http://www.serif.com/"
|
||||
width="38" height="30" viewBox="0 0 38 30" version="1.1" xml:space="preserve"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g>
|
||||
<clipPath id="_clip1">
|
||||
<rect x="0" y="-0.018" width="140" height="30.018"></rect>
|
||||
</clipPath>
|
||||
<g clip-path="url(#_clip1)">
|
||||
<path
|
||||
d="M37.413,12.315c-0.933,-0.627 -3.382,-0.896 -5.163,-0.415c-0.097,-1.774 -1.012,-3.27 -2.685,-4.573l-0.619,-0.416l-0.414,0.624c-0.811,1.232 -1.153,2.873 -1.032,4.366c0.095,0.92 0.415,1.953 1.032,2.703c-2.318,1.345 -4.455,1.039 -13.919,1.039l-14.61,0c-0.042,2.137 0.302,6.248 2.915,9.593c0.289,0.37 0.604,0.727 0.948,1.072c2.126,2.128 5.336,3.689 10.138,3.693c7.325,0.007 13.601,-3.953 17.419,-13.527c1.257,0.02 4.572,0.225 6.195,-2.911c0.039,-0.052 0.413,-0.831 0.413,-0.831l-0.618,-0.415l0,-0.002Zm-27.875,-1.953l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm5.308,0l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm5.308,0l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm5.308,0l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm-21.232,0l-4.108,0l-0,4.108l4.108,0l0,-4.108Zm5.308,-5.19l-4.108,-0l-0,4.108l4.108,0l0,-4.108Zm5.308,-0l-4.108,-0l-0,4.108l4.108,0l0,-4.108Zm5.308,-0l-4.108,-0l-0,4.108l4.108,0l0,-4.108Zm0,-5.19l-4.108,0l-0,4.109l4.108,-0l0,-4.109Z"
|
||||
style="fill:var(--iconColor, #00153c);fill-rule:nonzero;"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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<String> ports;
|
||||
final String createdAt;
|
||||
final String uptime;
|
||||
|
||||
factory DockerContainer.fromJson(Map<String, dynamic> 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<DockerContainer> containers;
|
||||
String logs;
|
||||
String? errorMessage;
|
||||
bool loading;
|
||||
bool refreshing;
|
||||
final Set<String> operatingIds = {};
|
||||
}
|
||||
|
||||
class DockerSessionManager extends ChangeNotifier {
|
||||
final Map<String, DockerSessionState> _sessions = {};
|
||||
final Map<String, List<StreamSubscription<dynamic>>> _subscriptions = {};
|
||||
String? _activeHostId;
|
||||
|
||||
List<DockerSessionState> 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<void> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<List<DockerContainer>> _containersController =
|
||||
StreamController<List<DockerContainer>>.broadcast();
|
||||
final StreamController<String> _logsController =
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<DockerOperationResult> _operationController =
|
||||
StreamController<DockerOperationResult>.broadcast();
|
||||
final StreamController<String> _errorController =
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<bool> _connectedController =
|
||||
StreamController<bool>.broadcast();
|
||||
|
||||
sio.Socket? _socket;
|
||||
List<String> _serverCandidates = const [];
|
||||
int _candidateIndex = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
Stream<List<DockerContainer>> get containersStream =>
|
||||
_containersController.stream;
|
||||
Stream<String> get logsStream => _logsController.stream;
|
||||
Stream<DockerOperationResult> get operationStream =>
|
||||
_operationController.stream;
|
||||
Stream<String> get errorStream => _errorController.stream;
|
||||
Stream<bool> get connectedStream => _connectedController.stream;
|
||||
|
||||
bool get connected => _socket?.connected ?? false;
|
||||
|
||||
Future<void> 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<Map>()
|
||||
.map(
|
||||
(item) => DockerContainer.fromJson(
|
||||
item.map((key, value) => MapEntry(key.toString(), value)),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
});
|
||||
|
||||
socket.on('docker_containers_logs', (data) {
|
||||
_logsController.add(data?.toString() ?? '');
|
||||
});
|
||||
|
||||
socket.on('docker_operation_result', (data) {
|
||||
_operationController.add(DockerOperationResult.fromJson(data));
|
||||
});
|
||||
|
||||
socket.on('docker_connect_fail', (_) {
|
||||
_emitError('docker_connect_fail');
|
||||
});
|
||||
|
||||
socket.on('docker_not_plus', (_) {
|
||||
_emitError('docker_not_plus');
|
||||
});
|
||||
|
||||
_socket = socket;
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
bool _tryNextCandidate(String cookie) {
|
||||
final hasNext = _candidateIndex + 1 < _serverCandidates.length;
|
||||
if (!hasNext) return false;
|
||||
_candidateIndex++;
|
||||
_connectCandidate(cookie);
|
||||
return true;
|
||||
}
|
||||
|
||||
void refresh() {
|
||||
_socket?.emit('docker_get_containers_data');
|
||||
}
|
||||
|
||||
void getLogs(String containerId, {int tail = 2000}) {
|
||||
_socket?.emit('docker_get_containers_logs', {
|
||||
'containerId': containerId,
|
||||
'tail': tail,
|
||||
});
|
||||
}
|
||||
|
||||
void start(String containerId) =>
|
||||
_socket?.emit('docker_start_container', {'containerId': containerId});
|
||||
|
||||
void stop(String containerId) =>
|
||||
_socket?.emit('docker_stop_container', {'containerId': containerId});
|
||||
|
||||
void restart(String containerId) =>
|
||||
_socket?.emit('docker_restart_container', {'containerId': containerId});
|
||||
|
||||
void delete(String containerId) =>
|
||||
_socket?.emit('docker_delete_container', {'containerId': containerId});
|
||||
|
||||
void _emitError(String message) {
|
||||
if (!_disposed) _errorController.add(message);
|
||||
}
|
||||
|
||||
String _stringifyError(Object? error) {
|
||||
if (error == null) return 'Connection error';
|
||||
if (error is Map) {
|
||||
final message = error['message'] ?? error['error'] ?? error['data'];
|
||||
return message?.toString() ?? error.toString();
|
||||
}
|
||||
return error.toString();
|
||||
}
|
||||
|
||||
List<String> _buildServerCandidates(String serverAddress) {
|
||||
if (!kDebugMode) return [serverAddress];
|
||||
final parsed = Uri.tryParse(serverAddress);
|
||||
if (parsed == null || parsed.host.isEmpty) return [serverAddress];
|
||||
final candidates = <String>[];
|
||||
if (parsed.scheme == 'http' && parsed.port != 8082) {
|
||||
candidates.add(parsed.replace(port: 8082).toString());
|
||||
}
|
||||
if (parsed.scheme == 'https' && parsed.port != 8092) {
|
||||
candidates.add(parsed.replace(port: 8092).toString());
|
||||
}
|
||||
candidates.add(serverAddress);
|
||||
return LinkedHashSet<String>.from(candidates).toList(growable: false);
|
||||
}
|
||||
|
||||
void disconnect({bool closeStreams = true}) {
|
||||
final socket = _socket;
|
||||
_socket = null;
|
||||
socket?.dispose();
|
||||
if (closeStreams) {
|
||||
_disposed = true;
|
||||
_containersController.close();
|
||||
_logsController.close();
|
||||
_operationController.close();
|
||||
_errorController.close();
|
||||
_connectedController.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ import '../../state/app_update_notifier.dart';
|
||||
import '../../state/auth_notifier.dart';
|
||||
import '../../state/plus_info_notifier.dart';
|
||||
import '../../state/terminal_providers.dart';
|
||||
import '../docker/docker_icon.dart';
|
||||
import '../docker/docker_tab.dart';
|
||||
import '../servers/servers_tab.dart';
|
||||
import 'scripts_tab.dart';
|
||||
import 'settings_tab.dart';
|
||||
@@ -34,6 +36,7 @@ class _MainShellPageState extends ConsumerState<MainShellPage> {
|
||||
static const _tabs = <Widget>[
|
||||
ServersTab(),
|
||||
SftpTab(),
|
||||
DockerTab(),
|
||||
ScriptsTab(),
|
||||
SettingsTab(),
|
||||
];
|
||||
@@ -91,19 +94,23 @@ class _MainShellPageState extends ConsumerState<MainShellPage> {
|
||||
onSelected: (i) => setState(() => _index = i),
|
||||
items: [
|
||||
_WarmBottomBarItem(
|
||||
icon: Icons.monitor_outlined,
|
||||
icon: const Icon(Icons.monitor_outlined),
|
||||
label: l.tr('tabs.servers'),
|
||||
),
|
||||
_WarmBottomBarItem(
|
||||
icon: Icons.folder_outlined,
|
||||
icon: const Icon(Icons.folder_outlined),
|
||||
label: l.tr('tabs.sftp'),
|
||||
),
|
||||
_WarmBottomBarItem(
|
||||
icon: Icons.article_outlined,
|
||||
icon: const DockerIcon(),
|
||||
label: l.tr('tabs.docker'),
|
||||
),
|
||||
_WarmBottomBarItem(
|
||||
icon: const Icon(Icons.article_outlined),
|
||||
label: l.tr('tabs.scripts'),
|
||||
),
|
||||
_WarmBottomBarItem(
|
||||
icon: Icons.settings_outlined,
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
label: l.tr('tabs.settings'),
|
||||
),
|
||||
],
|
||||
@@ -276,7 +283,9 @@ class _WarmBottomBarButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = selected ? context.colors.fontOnPrimary : context.colors.softMuted;
|
||||
final color = selected
|
||||
? context.colors.fontOnPrimary
|
||||
: context.colors.softMuted;
|
||||
return Tooltip(
|
||||
message: item.label,
|
||||
child: Material(
|
||||
@@ -287,7 +296,7 @@ class _WarmBottomBarButton extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
child: IconTheme(
|
||||
data: IconThemeData(size: 22, color: color),
|
||||
child: Center(child: Icon(item.icon)),
|
||||
child: Center(child: item.icon),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -298,6 +307,6 @@ class _WarmBottomBarButton extends StatelessWidget {
|
||||
class _WarmBottomBarItem {
|
||||
const _WarmBottomBarItem({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final Widget icon;
|
||||
final String label;
|
||||
}
|
||||
|
||||
@@ -1332,11 +1332,7 @@ class _SftpDirectoryPickerSheetState extends State<_SftpDirectoryPickerSheet> {
|
||||
),
|
||||
Expanded(
|
||||
child: _loading
|
||||
? Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: c.primary,
|
||||
),
|
||||
)
|
||||
? Center(child: CircularProgressIndicator(color: c.primary))
|
||||
: _error != null
|
||||
? Center(
|
||||
child: Padding(
|
||||
@@ -1643,9 +1639,7 @@ class _SftpFileRow extends StatelessWidget {
|
||||
? Icons.link_rounded
|
||||
: Icons.insert_drive_file_outlined,
|
||||
size: 20,
|
||||
color: entry.isDirectory
|
||||
? c.accent
|
||||
: c.muted,
|
||||
color: entry.isDirectory ? c.accent : c.muted,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -1667,10 +1661,7 @@ class _SftpFileRow extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: c.softMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: TextStyle(color: c.softMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -1681,10 +1672,7 @@ class _SftpFileRow extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.right,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: c.softMuted,
|
||||
fontSize: 10,
|
||||
),
|
||||
style: TextStyle(color: c.softMuted, fontSize: 10),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1791,29 +1779,35 @@ class _SftpEmptyCard extends StatelessWidget {
|
||||
children: [
|
||||
const _SftpOrbitIcon(),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l.tr('sftp.emptyTitle'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: c.text,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
SizedBox(
|
||||
height: 24,
|
||||
child: Text(
|
||||
l.tr('sftp.emptyTitle'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: c.text,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l.tr('sftp.emptyBody'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: c.softMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: Text(
|
||||
l.tr('sftp.emptyBody'),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: c.softMuted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_SftpPrimaryButton(
|
||||
icon: Icons.dns_outlined,
|
||||
icon: Icons.folder_outlined,
|
||||
label: l.tr('sftp.chooseServer'),
|
||||
trailing: Icons.keyboard_arrow_down_rounded,
|
||||
onTap: onChooseServer,
|
||||
@@ -1864,11 +1858,7 @@ class _SftpOrbitIcon extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: const Color(0x66E5B33A)),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.folder_rounded,
|
||||
color: c.primary,
|
||||
size: 42,
|
||||
),
|
||||
child: Icon(Icons.folder_rounded, color: c.primary, size: 42),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1986,7 +1976,9 @@ class _SftpServerPickerSheetState
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: c.canvas,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
border: Border(top: BorderSide(color: c.border)),
|
||||
),
|
||||
child: Column(
|
||||
@@ -2043,10 +2035,7 @@ class _SftpServerPickerSheetState
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
cursorColor: c.primary,
|
||||
style: TextStyle(
|
||||
color: c.text,
|
||||
fontSize: 14,
|
||||
),
|
||||
style: TextStyle(color: c.text, fontSize: 14),
|
||||
decoration: _sftpSearchDecoration(
|
||||
context,
|
||||
hintText: l.tr('sftp.searchHint'),
|
||||
@@ -2059,9 +2048,7 @@ class _SftpServerPickerSheetState
|
||||
Expanded(
|
||||
child: hostsAsync.when(
|
||||
loading: () => Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: c.primary,
|
||||
),
|
||||
child: CircularProgressIndicator(color: c.primary),
|
||||
),
|
||||
error: (error, _) => ListView(
|
||||
controller: scrollController,
|
||||
@@ -2230,11 +2217,9 @@ class _SftpServerRow extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.dns_outlined,
|
||||
Icons.folder_outlined,
|
||||
size: 21,
|
||||
color: connected
|
||||
? c.primary
|
||||
: c.muted,
|
||||
color: connected ? c.primary : c.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -2269,11 +2254,7 @@ class _SftpServerRow extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (active)
|
||||
Icon(
|
||||
Icons.check_rounded,
|
||||
color: c.success,
|
||||
size: 20,
|
||||
)
|
||||
Icon(Icons.check_rounded, color: c.success, size: 20)
|
||||
else if (!connected)
|
||||
_SftpStatusPill(
|
||||
label: l.tr('sftp.statusDisconnected'),
|
||||
@@ -2389,11 +2370,7 @@ InputDecoration _sftpSearchDecoration(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: c.chip,
|
||||
prefixIcon: Icon(
|
||||
Icons.search_rounded,
|
||||
size: 18,
|
||||
color: c.softMuted,
|
||||
),
|
||||
prefixIcon: Icon(Icons.search_rounded, size: 18, color: c.softMuted),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
hintStyle: TextStyle(color: c.softMuted, fontSize: 13),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
|
||||
@@ -56,6 +56,7 @@ const Map<String, String> stringsEn = {
|
||||
// Main shell tabs
|
||||
'tabs.servers': 'Servers',
|
||||
'tabs.sftp': 'SFTP',
|
||||
'tabs.docker': 'Docker',
|
||||
'tabs.scripts': 'Scripts',
|
||||
'tabs.settings': 'Settings',
|
||||
|
||||
@@ -191,6 +192,45 @@ const Map<String, String> stringsEn = {
|
||||
'This system picker did not provide a readable folder path.',
|
||||
'sftp.chooseTargetFolder': 'Choose target folder',
|
||||
'sftp.selectThisFolder': 'Select this folder',
|
||||
|
||||
// Docker
|
||||
'docker.emptyTitle': 'No server selected',
|
||||
'docker.emptyBody': 'Choose a Linux server to manage Docker containers.',
|
||||
'docker.chooseServer': 'Choose server',
|
||||
'docker.switchServer': 'Switch server',
|
||||
'docker.sheetTitle': 'Select server',
|
||||
'docker.sheetSubtitle': 'Connected Docker sessions are shown first.',
|
||||
'docker.searchHint': 'Search by name, host, or user',
|
||||
'docker.connectedSection': 'Connected',
|
||||
'docker.disconnectedSection': 'Not connected',
|
||||
'docker.statusConnected': 'Connected',
|
||||
'docker.statusDisconnected': 'Not connected',
|
||||
'docker.connecting': 'Connecting Docker...',
|
||||
'docker.noContainers': 'No containers found.',
|
||||
'docker.plusRequired': 'Docker management requires server authorization.',
|
||||
'docker.connectFailed': 'Docker service connection failed.',
|
||||
'docker.notPlus': 'Docker management is not enabled on this server.',
|
||||
'docker.refresh': 'Refresh',
|
||||
'docker.logs': 'Logs',
|
||||
'docker.start': 'Start',
|
||||
'docker.stop': 'Stop',
|
||||
'docker.restart': 'Restart',
|
||||
'docker.delete': 'Delete',
|
||||
'docker.deleteTitle': 'Delete container?',
|
||||
'docker.deleteBody': 'Delete container "{0}"? This cannot be undone.',
|
||||
'docker.operationFailed': 'Operation failed: {0}',
|
||||
'docker.logsTitle': '{0} logs',
|
||||
'docker.noLogs': 'No logs.',
|
||||
'docker.openPortFailed': 'Unable to parse port mapping.',
|
||||
'docker.selectAll': 'Select all',
|
||||
'docker.selectedCount': '{0} selected',
|
||||
'docker.batchStart': 'Start',
|
||||
'docker.batchRestart': 'Restart',
|
||||
'docker.batchStop': 'Stop',
|
||||
'docker.batchDelete': 'Delete',
|
||||
'docker.batchDeleteTitle': 'Delete {0} containers?',
|
||||
'docker.batchDeleteBody':
|
||||
'Are you sure you want to delete {0} selected containers? This cannot be undone.',
|
||||
'scripts.placeholder': 'Coming soon: script library',
|
||||
|
||||
// Scripts tab
|
||||
|
||||
@@ -53,6 +53,7 @@ const Map<String, String> stringsZh = {
|
||||
// Tabs
|
||||
'tabs.servers': '服务器',
|
||||
'tabs.sftp': '文件',
|
||||
'tabs.docker': '容器',
|
||||
'tabs.scripts': '脚本',
|
||||
'tabs.settings': '设置',
|
||||
|
||||
@@ -179,6 +180,44 @@ const Map<String, String> stringsZh = {
|
||||
'sftp.folderUploadUnsupported': '系统选择器没有返回可读取的文件夹路径。',
|
||||
'sftp.chooseTargetFolder': '选择目标文件夹',
|
||||
'sftp.selectThisFolder': '选择此文件夹',
|
||||
|
||||
// Docker
|
||||
'docker.emptyTitle': '还没有选择服务器',
|
||||
'docker.emptyBody': '请选择一台 Linux 服务器,开始管理 Docker 容器。',
|
||||
'docker.chooseServer': '选择服务器',
|
||||
'docker.switchServer': '切换服务器',
|
||||
'docker.sheetTitle': '选择服务器',
|
||||
'docker.sheetSubtitle': '已连接的 Docker 会话会优先显示。',
|
||||
'docker.searchHint': '按名称 / 主机 / 用户名搜索',
|
||||
'docker.connectedSection': '已连接',
|
||||
'docker.disconnectedSection': '未连接',
|
||||
'docker.statusConnected': '已连接',
|
||||
'docker.statusDisconnected': '未连接',
|
||||
'docker.connecting': '正在连接 Docker...',
|
||||
'docker.noContainers': '暂无容器',
|
||||
'docker.plusRequired': 'Docker 管理需要服务端授权。',
|
||||
'docker.connectFailed': 'Docker 服务连接失败。',
|
||||
'docker.notPlus': '当前服务端未启用 Docker 管理能力。',
|
||||
'docker.refresh': '刷新',
|
||||
'docker.logs': '日志',
|
||||
'docker.start': '启动',
|
||||
'docker.stop': '停止',
|
||||
'docker.restart': '重启',
|
||||
'docker.delete': '删除',
|
||||
'docker.deleteTitle': '删除容器?',
|
||||
'docker.deleteBody': '确认删除容器“{0}”?此操作无法撤销。',
|
||||
'docker.operationFailed': '操作失败:{0}',
|
||||
'docker.logsTitle': '{0} 日志',
|
||||
'docker.noLogs': '暂无日志',
|
||||
'docker.openPortFailed': '无法解析端口映射。',
|
||||
'docker.selectAll': '全选',
|
||||
'docker.selectedCount': '已选中 {0} 个',
|
||||
'docker.batchStart': '启动',
|
||||
'docker.batchRestart': '重启',
|
||||
'docker.batchStop': '停止',
|
||||
'docker.batchDelete': '删除',
|
||||
'docker.batchDeleteTitle': '删除 {0} 个容器?',
|
||||
'docker.batchDeleteBody': '确认删除 {0} 个已选容器?此操作无法撤销。',
|
||||
'scripts.placeholder': '即将上线:脚本库',
|
||||
|
||||
// Scripts tab
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../features/docker/docker_session_manager.dart';
|
||||
|
||||
final dockerSessionManagerProvider = Provider<DockerSessionManager>((ref) {
|
||||
final manager = DockerSessionManager();
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
});
|
||||
@@ -900,6 +900,22 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
socket_io_client:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: socket_io_client
|
||||
sha256: f5990ff303d385e7b2150f0e57cca097a8d20c6a2ae0a22cb2d496661ea8ed9b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
socket_io_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: socket_io_common
|
||||
sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1102,6 +1118,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -42,6 +42,7 @@ dependencies:
|
||||
photo_view: ^0.15.0
|
||||
url_launcher: ^6.3.1
|
||||
package_info_plus: ^8.1.0
|
||||
socket_io_client: ^3.1.6
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@@ -107,5 +108,6 @@ flutter:
|
||||
assets:
|
||||
- assets/logo_v2_01.png
|
||||
- assets/plus.png
|
||||
- assets/docker.svg
|
||||
- assets/linux.svg
|
||||
- assets/windows.svg
|
||||
|
||||
Reference in New Issue
Block a user