mirror of
https://github.com/lollipopkit/flutter_server_box.git
synced 2026-01-17 14:34:33 +01:00
* fix(ssh): Modify the return type of execWithPwd to include the output content
Adjust the return type of the `execWithPwd` method to `(int?, String)` so that it can simultaneously return the exit code and output content
Fix the issue in ContainerNotifier where the return result of execWithPwd is not handled correctly
Ensure that server operations (shutdown/restart/suspend) are correctly pending until the command execution is completed
* refactor(container): Change single error handling to multiple error lists
Support the simultaneous display of multiple container operation errors, enhancing error handling capabilities
* fix(container): Adjust the layout width and optimize the handling of text overflow
Adjust the width calculation for the container page layout, changing from subtracting a fixed value to subtracting a smaller value to improve the layout
Add overflow ellipsis processing to the text to prevent anomalies when the text is too long
* Revert "refactor(container): Change single error handling to multiple error lists"
This reverts commit 72aaa173f5.
* feat(container): Add Podman Docker emulation detection function
Add detection for Podman Docker emulation in the container module. When detected, a prompt message will be displayed and users will be advised to switch to Podman settings.
Updated the multilingual translation files to support the new features.
* fix: Fix error handling in SSH client and container operations
Fix the issue where the SSH client does not handle stderr when executing commands
Error handling for an empty client in the container addition operation
Fix the issue where null may be returned during server page operations
* fix(container): Check if client is empty before running the command
When the client is null, directly return an error to avoid null pointer exception
* fix: Revert `stderr` ignore
* fix(container): Detect Podman simulation in advance and optimize error handling
Move the Podman simulation detection to the initial parsing stage to avoid redundant checks
Remove duplicated error handling code and simplify the logic
* fix(container): Fix the error handling logic during container command execution
Increase the inspection of error outputs, including handling situations such as sudo password prompts and Podman not being installed
* refactor(macOS): Remove unused path_provider_foundation plugin
353 lines
12 KiB
Dart
353 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:dartssh2/dartssh2.dart';
|
|
import 'package:fl_lib/fl_lib.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
import 'package:server_box/core/extension/context/locale.dart';
|
|
import 'package:server_box/core/extension/ssh_client.dart';
|
|
import 'package:server_box/data/model/app/error.dart';
|
|
import 'package:server_box/data/model/app/scripts/script_consts.dart';
|
|
import 'package:server_box/data/model/container/image.dart';
|
|
import 'package:server_box/data/model/container/ps.dart';
|
|
import 'package:server_box/data/model/container/type.dart';
|
|
import 'package:server_box/data/res/store.dart';
|
|
|
|
part 'container.freezed.dart';
|
|
part 'container.g.dart';
|
|
|
|
final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found");
|
|
final _podmanEmulationMsg = 'Emulate Docker CLI using podman';
|
|
|
|
@freezed
|
|
abstract class ContainerState with _$ContainerState {
|
|
const factory ContainerState({
|
|
@Default(null) List<ContainerPs>? items,
|
|
@Default(null) List<ContainerImg>? images,
|
|
@Default(null) String? version,
|
|
@Default(null) ContainerErr? error,
|
|
@Default(null) String? runLog,
|
|
@Default(ContainerType.docker) ContainerType type,
|
|
@Default(false) bool isBusy,
|
|
}) = _ContainerState;
|
|
}
|
|
|
|
@riverpod
|
|
class ContainerNotifier extends _$ContainerNotifier {
|
|
var sudoCompleter = Completer<bool>();
|
|
|
|
@override
|
|
ContainerState build(SSHClient? client, String userName, String hostId, BuildContext context) {
|
|
final type = Stores.container.getType(hostId);
|
|
final initialState = ContainerState(type: type);
|
|
|
|
// Async initialization
|
|
Future.microtask(() => refresh());
|
|
|
|
return initialState;
|
|
}
|
|
|
|
Future<void> setType(ContainerType type) async {
|
|
state = state.copyWith(type: type, error: null, runLog: null, items: null, images: null, version: null);
|
|
Stores.container.setType(type, hostId);
|
|
sudoCompleter = Completer<bool>();
|
|
await refresh();
|
|
}
|
|
|
|
void _requiresSudo() async {
|
|
/// Podman is rootless
|
|
if (state.type == ContainerType.podman) return sudoCompleter.complete(false);
|
|
if (!Stores.setting.containerTrySudo.fetch()) {
|
|
return sudoCompleter.complete(false);
|
|
}
|
|
|
|
final res = await client?.run(_wrap(ContainerCmdType.images.exec(state.type)));
|
|
if (res?.string.toLowerCase().contains('permission denied') ?? false) {
|
|
return sudoCompleter.complete(true);
|
|
}
|
|
return sudoCompleter.complete(false);
|
|
}
|
|
|
|
Future<void> refresh({bool isAuto = false}) async {
|
|
if (state.isBusy) return;
|
|
state = state.copyWith(isBusy: true);
|
|
|
|
if (!sudoCompleter.isCompleted) _requiresSudo();
|
|
|
|
final sudo = await sudoCompleter.future;
|
|
|
|
/// If sudo is required and auto refresh is enabled, skip the refresh.
|
|
/// Or this will ask for pwd again and again.
|
|
if (sudo && isAuto) {
|
|
state = state.copyWith(isBusy: false);
|
|
return;
|
|
}
|
|
final includeStats = Stores.setting.containerParseStat.fetch();
|
|
|
|
final cmd = _wrap(ContainerCmdType.execAll(state.type, sudo: sudo, includeStats: includeStats));
|
|
int? code;
|
|
String raw = '';
|
|
final errs = <String>[];
|
|
if (client != null) {
|
|
(code, raw) = await client!.execWithPwd(cmd, context: context, id: hostId);
|
|
} else {
|
|
state = state.copyWith(
|
|
isBusy: false,
|
|
error: ContainerErr(type: ContainerErrType.noClient),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!ref.mounted) return;
|
|
state = state.copyWith(isBusy: false);
|
|
|
|
if (!context.mounted) return;
|
|
|
|
/// Code 127 means command not found
|
|
if (code == 127 || raw.contains(_dockerNotFound) || errs.join().contains(_dockerNotFound)) {
|
|
state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled));
|
|
return;
|
|
}
|
|
|
|
/// Pre-parse Podman detection
|
|
if (raw.contains(_podmanEmulationMsg)) {
|
|
state = state.copyWith(
|
|
error: ContainerErr(
|
|
type: ContainerErrType.podmanDetected,
|
|
message: l10n.podmanDockerEmulationDetected,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
/// Filter out sudo password prompt from output
|
|
if (errs.any((e) => e.contains('[sudo] password'))) {
|
|
raw = raw.split('\n').where((line) => !line.contains('[sudo] password')).join('\n');
|
|
}
|
|
|
|
/// Detect Podman not installed when using Podman mode
|
|
if (state.type == ContainerType.podman &&
|
|
(errs.any((e) => e.contains('podman: not found')) ||
|
|
raw.contains('podman: not found'))) {
|
|
state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled));
|
|
return;
|
|
}
|
|
|
|
// Check result segments count
|
|
final segments = raw.split(ScriptConstants.separator);
|
|
if (segments.length != ContainerCmdType.values.length) {
|
|
state = state.copyWith(
|
|
error: ContainerErr(
|
|
type: ContainerErrType.segmentsNotMatch,
|
|
message: 'Container segments: ${segments.length}',
|
|
),
|
|
);
|
|
Loggers.app.warning('Container segments: ${segments.length}\n$raw');
|
|
return;
|
|
}
|
|
|
|
// Parse version
|
|
final verRaw = ContainerCmdType.version.find(segments);
|
|
try {
|
|
final version = json.decode(verRaw)['Client']['Version'];
|
|
state = state.copyWith(version: version, error: null);
|
|
} catch (e, trace) {
|
|
if (state.error == null) {
|
|
state = state.copyWith(
|
|
error: ContainerErr(type: ContainerErrType.invalidVersion, message: '$e'),
|
|
);
|
|
}
|
|
Loggers.app.warning('Container version failed', e, trace);
|
|
}
|
|
|
|
// Parse ps
|
|
final psRaw = ContainerCmdType.ps.find(segments);
|
|
try {
|
|
final lines = psRaw.split('\n');
|
|
if (state.type == ContainerType.docker) {
|
|
/// Due to the fetched data is not in json format, skip table header
|
|
lines.removeWhere((element) => element.contains('CONTAINER ID'));
|
|
}
|
|
lines.removeWhere((element) => element.isEmpty);
|
|
final items = lines.map((e) => ContainerPs.fromRaw(e, state.type)).toList();
|
|
state = state.copyWith(items: items);
|
|
} catch (e, trace) {
|
|
if (state.error == null) {
|
|
state = state.copyWith(
|
|
error: ContainerErr(type: ContainerErrType.parsePs, message: '$e'),
|
|
);
|
|
}
|
|
Loggers.app.warning('Container ps failed', e, trace);
|
|
}
|
|
|
|
// Parse images
|
|
final imageRaw = ContainerCmdType.images.find(segments).trim();
|
|
final isEntireJson = imageRaw.startsWith('[') && imageRaw.endsWith(']');
|
|
try {
|
|
List<ContainerImg> images;
|
|
if (isEntireJson) {
|
|
images = (json.decode(imageRaw) as List)
|
|
.map((e) => ContainerImg.fromRawJson(json.encode(e), state.type))
|
|
.toList();
|
|
} else {
|
|
final lines = imageRaw.split('\n');
|
|
lines.removeWhere((element) => element.isEmpty);
|
|
images = lines.map((e) => ContainerImg.fromRawJson(e, state.type)).toList();
|
|
}
|
|
state = state.copyWith(images: images);
|
|
} catch (e, trace) {
|
|
if (state.error == null) {
|
|
state = state.copyWith(
|
|
error: ContainerErr(type: ContainerErrType.parseImages, message: '$e'),
|
|
);
|
|
}
|
|
Loggers.app.warning('Container images failed', e, trace);
|
|
}
|
|
|
|
// Parse stats
|
|
final statsRaw = ContainerCmdType.stats.find(segments);
|
|
try {
|
|
final statsLines = statsRaw.split('\n');
|
|
statsLines.removeWhere((element) => element.isEmpty);
|
|
final items = state.items;
|
|
if (items == null) return;
|
|
|
|
for (var item in items) {
|
|
final id = item.id;
|
|
if (id == null) continue;
|
|
if (id.length < 5) continue;
|
|
final statsLine = statsLines.firstWhereOrNull(
|
|
/// Use 5 characters to match the container id, possibility of mismatch
|
|
/// is very low.
|
|
(element) => element.contains(id.substring(0, 5)),
|
|
);
|
|
if (statsLine == null) continue;
|
|
item.parseStats(statsLine, state.version);
|
|
}
|
|
} catch (e, trace) {
|
|
if (state.error == null) {
|
|
state = state.copyWith(
|
|
error: ContainerErr(type: ContainerErrType.parseStats, message: '$e'),
|
|
);
|
|
}
|
|
Loggers.app.warning('Parse docker stats: $statsRaw', e, trace);
|
|
}
|
|
}
|
|
|
|
Future<ContainerErr?> stop(String id) async => await run('stop $id');
|
|
|
|
Future<ContainerErr?> start(String id) async => await run('start $id');
|
|
|
|
Future<ContainerErr?> delete(String id, bool force) async {
|
|
if (force) {
|
|
return await run('rm -f $id');
|
|
}
|
|
return await run('rm $id');
|
|
}
|
|
|
|
Future<ContainerErr?> restart(String id) async => await run('restart $id');
|
|
|
|
Future<ContainerErr?> pruneImages({bool all = true}) async {
|
|
final cmd = 'image prune${all ? " -a" : ""} -f';
|
|
return await run(cmd);
|
|
}
|
|
|
|
Future<ContainerErr?> pruneContainers() async {
|
|
return await run('container prune -f');
|
|
}
|
|
|
|
Future<ContainerErr?> pruneVolumes() async {
|
|
return await run('volume prune -f');
|
|
}
|
|
|
|
Future<ContainerErr?> pruneSystem() async {
|
|
return await run('system prune -a -f --volumes');
|
|
}
|
|
|
|
Future<ContainerErr?> run(String cmd, {bool autoRefresh = true}) async {
|
|
if (client == null) {
|
|
return ContainerErr(type: ContainerErrType.noClient);
|
|
}
|
|
|
|
cmd = switch (state.type) {
|
|
ContainerType.docker => 'docker $cmd',
|
|
ContainerType.podman => 'podman $cmd',
|
|
};
|
|
|
|
state = state.copyWith(runLog: '');
|
|
final errs = <String>[];
|
|
final (code, _) = await client?.execWithPwd(
|
|
_wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd),
|
|
context: context,
|
|
onStdout: (data, _) {
|
|
state = state.copyWith(runLog: '${state.runLog}$data');
|
|
},
|
|
onStderr: (data, _) => errs.add(data),
|
|
id: hostId,
|
|
) ?? (null, null);
|
|
state = state.copyWith(runLog: null);
|
|
|
|
if (code != 0) {
|
|
return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim());
|
|
}
|
|
if (autoRefresh) await refresh();
|
|
return null;
|
|
}
|
|
|
|
/// wrap cmd with `docker host`
|
|
String _wrap(String cmd) {
|
|
final dockerHost = Stores.container.fetch(hostId);
|
|
cmd = 'export LANG=en_US.UTF-8 && $cmd';
|
|
final noDockerHost = dockerHost?.isEmpty ?? true;
|
|
if (!noDockerHost) {
|
|
cmd = 'export DOCKER_HOST=$dockerHost && $cmd';
|
|
}
|
|
return cmd;
|
|
}
|
|
}
|
|
|
|
const _jsonFmt = '--format "{{json .}}"';
|
|
|
|
enum ContainerCmdType {
|
|
version,
|
|
ps,
|
|
stats,
|
|
images
|
|
// No specific commands needed for prune actions as they are simple
|
|
// and don't require splitting output with ScriptConstants.separator
|
|
;
|
|
|
|
String exec(ContainerType type, {bool sudo = false, bool includeStats = false}) {
|
|
final prefix = sudo ? 'sudo -S ${type.name}' : type.name;
|
|
return switch (this) {
|
|
ContainerCmdType.version => '$prefix version $_jsonFmt',
|
|
ContainerCmdType.ps => switch (type) {
|
|
/// TODO: Rollback to json format when performance recovers.
|
|
/// Use [_jsonFmt] in Docker will cause the operation to slow down.
|
|
ContainerType.docker =>
|
|
'$prefix ps -a --format "table {{printf \\"'
|
|
'%-15.15s '
|
|
'%-30.30s '
|
|
'${"%-50.50s " * 2}\\"'
|
|
' .ID .Status .Names .Image}}"',
|
|
ContainerType.podman => '$prefix ps -a $_jsonFmt',
|
|
},
|
|
ContainerCmdType.stats => includeStats ? '$prefix stats --no-stream $_jsonFmt' : 'echo PASS',
|
|
ContainerCmdType.images => '$prefix image ls $_jsonFmt',
|
|
};
|
|
}
|
|
|
|
static String execAll(ContainerType type, {bool sudo = false, bool includeStats = false}) {
|
|
return ContainerCmdType.values
|
|
.map((e) => e.exec(type, sudo: sudo, includeStats: includeStats))
|
|
.join('\necho ${ScriptConstants.separator}\n');
|
|
}
|
|
|
|
/// Find out the required segment from [segments]
|
|
String find(List<String> segments) {
|
|
return segments[index];
|
|
}
|
|
}
|