Files
flutter_server_box/lib/view/page/container/container.dart
GT610 39a3e0800b opt: Improve container parsing and error handling (#1001)
* 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
2026-01-17 14:40:44 +08:00

381 lines
12 KiB
Dart

import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/model/app/menu/base.dart';
import 'package:server_box/data/model/app/menu/container.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/provider/container.dart';
import 'package:server_box/data/provider/server/single.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
part 'actions.dart';
part 'types.dart';
class ContainerPage extends ConsumerStatefulWidget {
final SpiRequiredArgs args;
const ContainerPage({required this.args, super.key});
@override
ConsumerState<ContainerPage> createState() => _ContainerPageState();
static const route = AppRouteArg(page: ContainerPage.new, path: '/container');
}
class _ContainerPageState extends ConsumerState<ContainerPage> {
final _textController = TextEditingController();
late final ContainerNotifierProvider _provider;
@override
void dispose() {
super.dispose();
_textController.dispose();
}
@override
void initState() {
super.initState();
final serverState = ref.read(serverProvider(widget.args.spi.id));
_provider = containerProvider(
serverState.client,
widget.args.spi.user,
widget.args.spi.id,
context,
);
_initAutoRefresh();
}
@override
Widget build(BuildContext context) {
final err = ref.watch(_provider.select((p) => p.error));
return Scaffold(
appBar: _buildAppBar(),
body: SafeArea(child: _buildMain()),
floatingActionButton: err == null ? _buildFAB() : null,
);
}
CustomAppBar _buildAppBar() {
return CustomAppBar(
centerTitle: true,
title: TwoLineText(up: l10n.container, down: widget.args.spi.name),
actions: [
IconButton(
onPressed: () => context.showLoadingDialog(fn: () => _containerNotifier.refresh()),
icon: const Icon(Icons.refresh),
),
],
);
}
Widget _buildFAB() {
return FloatingActionButton(onPressed: () async => await _showAddFAB(), child: const Icon(Icons.add));
}
Widget _buildMain() {
final containerState = _containerState;
if (containerState.error != null && containerState.items == null) {
return SizedBox.expand(
child: Column(
children: [
const Spacer(),
const Icon(Icons.error, size: 37),
UIs.height13,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 23),
child: Text(containerState.error.toString()),
),
const Spacer(),
UIs.height13,
_buildSettingsBtns,
],
).paddingSymmetric(horizontal: 13),
);
}
if (containerState.items == null || containerState.images == null) {
return UIs.centerLoading;
}
return AutoMultiList(
children: <Widget>[
_buildLoading(containerState),
_buildVersion(containerState),
_buildPs(containerState),
_buildImage(containerState),
_buildEmptyStateMessage(containerState),
_buildPruneBtns,
_buildSettingsBtns,
],
);
}
Widget _buildEmptyStateMessage(ContainerState containerState) {
final emptyImgs = containerState.images?.isEmpty ?? true;
final emptyPs = containerState.items?.isEmpty ?? true;
if (emptyPs && emptyImgs && containerState.runLog == null) {
return CardX(
child: Padding(
padding: const EdgeInsets.fromLTRB(17, 17, 17, 7),
child: SimpleMarkdown(data: l10n.dockerEmptyRunningItems),
),
);
}
return UIs.placeholder;
}
Widget _buildImage(ContainerState containerState) {
return ExpandTile(
leading: const Icon(MingCute.clapperboard_line),
title: Text(l10n.imagesList),
subtitle: Text(l10n.dockerImagesFmt(containerState.images?.length ?? 'null'), style: UIs.textGrey),
initiallyExpanded: (containerState.images?.length ?? 0) <= 3,
children: containerState.images?.map(_buildImageItem).toList() ?? [],
).cardx;
}
Widget _buildImageItem(ContainerImg e) {
final repoSplited = e.repository?.split('/');
final title = repoSplited?.lastOrNull ?? e.repository;
repoSplited?.removeLast();
final reg = repoSplited?.join('/');
return ListTile(
title: Text(title ?? l10n.unknown, style: UIs.text15),
subtitle: Text('${reg ?? ''} - ${e.tag} - ${e.sizeMB}', style: UIs.text13Grey),
trailing: Btn.icon(
padding: EdgeInsets.zero,
icon: const Icon(Icons.delete),
onTap: () => _showImageRmDialog(e),
),
);
}
Widget _buildLoading(ContainerState containerState) {
if (containerState.runLog == null) return UIs.placeholder;
return Padding(
padding: const EdgeInsets.all(17),
child: Column(
children: [
const Center(child: CircularProgressIndicator()),
UIs.height13,
Text(containerState.runLog ?? '...'),
],
),
);
}
Widget _buildVersion(ContainerState containerState) {
return CardX(
child: Padding(
padding: const EdgeInsets.all(17),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(containerState.type.name.capitalize), Text(containerState.version ?? l10n.unknown)],
),
),
);
}
Widget _buildPs(ContainerState containerState) {
final items = containerState.items;
if (items == null) return UIs.placeholder;
final running = items.where((e) => e.status.isRunning).length;
final stopped = items.length - running;
final subtitle = stopped > 0
? l10n.dockerStatusRunningAndStoppedFmt(running, stopped)
: l10n.dockerStatusRunningFmt(running);
return ExpandTile(
leading: const Icon(OctIcons.container, size: 22),
title: Text(l10n.container),
subtitle: Text(subtitle, style: UIs.textGrey),
initiallyExpanded: items.length < 7,
children: items.map(_buildPsItem).toList(),
).cardx;
}
Widget _buildPsItem(ContainerPs item) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 11),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.name ?? l10n.unknown, style: UIs.text15),
const SizedBox(height: 3),
_buildMoreBtn(item),
],
),
Text(
'${item.image ?? l10n.unknown} - ${switch (item) {
final PodmanPs ps => ps.status.displayName,
final DockerPs ps => ps.state ?? ps.status.displayName,
}}',
style: UIs.text13Grey,
),
_buildPsItemStats(item),
],
),
);
}
Widget _buildPsItemStats(ContainerPs item) {
if (item.cpu == null || item.mem == null) return UIs.placeholder;
return LayoutBuilder(
builder: (_, cons) {
final width = cons.maxWidth / 2 - 6.5;
return Column(
children: [
UIs.height13,
Row(
children: [
_buildPsItemStatsItem('CPU', item.cpu, Icons.memory, width: width),
UIs.width13,
_buildPsItemStatsItem('Net', item.net, Icons.network_cell, width: width),
],
),
Row(
children: [
_buildPsItemStatsItem('Mem', item.mem, Icons.settings_input_component, width: width),
UIs.width13,
_buildPsItemStatsItem('Disk', item.disk, Icons.storage, width: width),
],
),
],
);
},
);
}
Widget _buildPsItemStatsItem(String title, String? value, IconData icon, {required double width}) {
return SizedBox(
width: width,
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: Colors.grey),
UIs.width7,
Expanded(
child: Text(
value ?? l10n.unknown,
style: UIs.text11Grey,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
);
}
Widget _buildMoreBtn(ContainerPs dItem) {
return PopupMenu(
items: ContainerMenu.items(dItem.status.isRunning).map((e) => PopMenu.build(e, e.icon, e.toStr)).toList(),
onSelected: (item) => _onTapMoreBtn(item, dItem),
);
}
String _buildAddCmd(String image, String name, String args) {
var suffix = '';
if (args.isEmpty) {
suffix = image;
} else {
suffix = '$args $image';
}
if (name.isEmpty) {
return 'run -itd $suffix';
}
return 'run -itd --name $name $suffix';
}
Widget get _buildPruneBtns {
final len = _PruneTypes.values.length;
if (len == 0) return UIs.placeholder;
return ExpandTile(
leading: const Icon(Icons.delete),
title: Text(l10n.prune),
children: _PruneTypes.values.map(_buildPruneBtn).toList(),
).cardx;
}
Widget _buildPruneBtn(_PruneTypes type) {
final title = type.name.capitalize;
final containerNotifier = _containerNotifier;
return ListTile(
onTap: () async {
await _showPruneDialog(
title: title,
message: type.tip,
onConfirm: switch (type) {
_PruneTypes.images => containerNotifier.pruneImages,
_PruneTypes.containers => containerNotifier.pruneContainers,
_PruneTypes.volumes => containerNotifier.pruneVolumes,
_PruneTypes.system => containerNotifier.pruneSystem,
},
);
},
title: Text(title),
trailing: const Icon(Icons.keyboard_arrow_right),
);
}
Widget get _buildSettingsBtns {
final len = _SettingsMenuItems.values.length;
if (len == 0) return UIs.placeholder;
final containerState = _containerState;
return ExpandTile(
leading: const Icon(Icons.settings),
title: Text(libL10n.setting),
initiallyExpanded: containerState.error != null,
children: _SettingsMenuItems.values.map((item) => _buildSettingTile(item, containerState)).toList(),
).cardx;
}
Widget _buildSettingTile(_SettingsMenuItems item, ContainerState containerState) {
final String title;
switch (item) {
case _SettingsMenuItems.editDockerHost:
title = '${libL10n.edit} DOCKER_HOST';
break;
case _SettingsMenuItems.switchProvider:
title = containerState.type == ContainerType.podman
? l10n.switchTo('Docker')
: l10n.switchTo('Podman');
break;
}
return ListTile(
onTap: () {
switch (item) {
case _SettingsMenuItems.editDockerHost:
_showEditHostDialog();
break;
case _SettingsMenuItems.switchProvider:
ref
.read(_provider.notifier)
.setType(
containerState.type == ContainerType.docker ? ContainerType.podman : ContainerType.docker,
);
break;
}
},
title: Text(title),
trailing: const Icon(Icons.keyboard_arrow_right),
);
}
}