fix: container not working (#787)

This commit is contained in:
lollipopkit🏳️‍⚧️
2025-06-11 14:53:43 +08:00
committed by GitHub
parent 66ecb02d9e
commit e6db2db320
33 changed files with 700 additions and 594 deletions

View File

@@ -222,6 +222,23 @@ class ContainerProvider extends ChangeNotifier {
Future<ContainerErr?> restart(String id) async => await run('restart $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 { Future<ContainerErr?> run(String cmd, {bool autoRefresh = true}) async {
cmd = switch (type) { cmd = switch (type) {
ContainerType.docker => 'docker $cmd', ContainerType.docker => 'docker $cmd',
@@ -272,6 +289,8 @@ enum ContainerCmdType {
ps, ps,
stats, stats,
images, images,
// No specific commands needed for prune actions as they are simple
// and don't require splitting output with ShellFunc.seperator
; ;
String exec( String exec(

View File

@@ -176,8 +176,8 @@ class SettingStore extends HiveStore {
late final containerParseStat = propertyDefault('containerParseStat', true); late final containerParseStat = propertyDefault('containerParseStat', true);
/// Auto refresh container status /// Auto refresh container status
late final contaienrAutoRefresh = propertyDefault( late final containerAutoRefresh = propertyDefault(
'contaienrAutoRefresh', 'containerAutoRefresh',
true, true,
); );

View File

@@ -914,6 +914,12 @@ abstract class AppLocalizations {
/// **'Process'** /// **'Process'**
String get process; String get process;
/// No description provided for @prune.
///
/// In en, this message translates to:
/// **'Prune'**
String get prune;
/// No description provided for @pushToken. /// No description provided for @pushToken.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -452,6 +452,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get process => 'Prozess'; String get process => 'Prozess';
@override
String get prune => 'Beschneiden';
@override @override
String get pushToken => 'Push Token'; String get pushToken => 'Push Token';

View File

@@ -450,6 +450,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get process => 'Process'; String get process => 'Process';
@override
String get prune => 'Prune';
@override @override
String get pushToken => 'Push token'; String get pushToken => 'Push token';

View File

@@ -454,6 +454,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get process => 'Proceso'; String get process => 'Proceso';
@override
String get prune => 'Podar';
@override @override
String get pushToken => 'Token de notificaciones'; String get pushToken => 'Token de notificaciones';

View File

@@ -455,6 +455,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get process => 'Processus'; String get process => 'Processus';
@override
String get prune => 'Élaguer';
@override @override
String get pushToken => 'Jeton d\'identification'; String get pushToken => 'Jeton d\'identification';

View File

@@ -450,6 +450,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get process => 'Proses'; String get process => 'Proses';
@override
String get prune => 'Pangkas';
@override @override
String get pushToken => 'Dorong token'; String get pushToken => 'Dorong token';

View File

@@ -436,6 +436,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get process => 'プロセス'; String get process => 'プロセス';
@override
String get prune => '剪定する';
@override @override
String get pushToken => 'プッシュトークン'; String get pushToken => 'プッシュトークン';

View File

@@ -451,6 +451,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get process => 'Proces'; String get process => 'Proces';
@override
String get prune => 'Snoeien';
@override @override
String get pushToken => 'Push-token'; String get pushToken => 'Push-token';

View File

@@ -451,6 +451,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get process => 'Processo'; String get process => 'Processo';
@override
String get prune => 'Podar';
@override @override
String get pushToken => 'Token de notificação push'; String get pushToken => 'Token de notificação push';

View File

@@ -452,6 +452,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get process => 'Процесс'; String get process => 'Процесс';
@override
String get prune => 'Обрезать';
@override @override
String get pushToken => 'Токен уведомлений'; String get pushToken => 'Токен уведомлений';

View File

@@ -449,6 +449,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get process => 'İşlem'; String get process => 'İşlem';
@override
String get prune => 'Budamak';
@override @override
String get pushToken => 'Push belirteci'; String get pushToken => 'Push belirteci';

View File

@@ -454,6 +454,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get process => 'Процес'; String get process => 'Процес';
@override
String get prune => 'Обрізати';
@override @override
String get pushToken => 'Надіслати токен'; String get pushToken => 'Надіслати токен';

View File

@@ -431,6 +431,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get process => '进程'; String get process => '进程';
@override
String get prune => '修剪';
@override @override
String get pushToken => '消息推送 Token'; String get pushToken => '消息推送 Token';
@@ -1162,6 +1165,9 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override @override
String get process => '行程'; String get process => '行程';
@override
String get prune => '修剪';
@override @override
String get pushToken => '消息推送 Token'; String get pushToken => '消息推送 Token';

View File

@@ -132,6 +132,7 @@
"preview": "Vorschau", "preview": "Vorschau",
"privateKey": "Private Key", "privateKey": "Private Key",
"process": "Prozess", "process": "Prozess",
"prune": "Beschneiden",
"pushToken": "Push Token", "pushToken": "Push Token",
"pveIgnoreCertTip": "Nicht empfohlen, Achten Sie auf Sicherheitsrisiken! Wenn Sie das Standardzertifikat von PVE verwenden, müssen Sie diese Option aktivieren.", "pveIgnoreCertTip": "Nicht empfohlen, Achten Sie auf Sicherheitsrisiken! Wenn Sie das Standardzertifikat von PVE verwenden, müssen Sie diese Option aktivieren.",
"pveLoginFailed": "Anmeldung fehlgeschlagen. Kann nicht mit Benutzername/Passwort aus der Serverkonfiguration angemeldet werden, um sich über Linux PAM anzumelden.", "pveLoginFailed": "Anmeldung fehlgeschlagen. Kann nicht mit Benutzername/Passwort aus der Serverkonfiguration angemeldet werden, um sich über Linux PAM anzumelden.",

View File

@@ -132,6 +132,7 @@
"preview": "Preview", "preview": "Preview",
"privateKey": "Private Key", "privateKey": "Private Key",
"process": "Process", "process": "Process",
"prune": "Prune",
"pushToken": "Push token", "pushToken": "Push token",
"pveIgnoreCertTip": "Not recommended to enable, beware of security risks! If you are using the default certificate from PVE, you need to enable this option.", "pveIgnoreCertTip": "Not recommended to enable, beware of security risks! If you are using the default certificate from PVE, you need to enable this option.",
"pveLoginFailed": "Login failed. Unable to authenticate with username/password from server configuration for Linux PAM login.", "pveLoginFailed": "Login failed. Unable to authenticate with username/password from server configuration for Linux PAM login.",

View File

@@ -132,6 +132,7 @@
"preview": "Vista previa", "preview": "Vista previa",
"privateKey": "Llave privada", "privateKey": "Llave privada",
"process": "Proceso", "process": "Proceso",
"prune": "Podar",
"pushToken": "Token de notificaciones", "pushToken": "Token de notificaciones",
"pveIgnoreCertTip": "No se recomienda activarlo, ¡tenga cuidado con los riesgos de seguridad! Si está utilizando el certificado predeterminado de PVE, debe habilitar esta opción.", "pveIgnoreCertTip": "No se recomienda activarlo, ¡tenga cuidado con los riesgos de seguridad! Si está utilizando el certificado predeterminado de PVE, debe habilitar esta opción.",
"pveLoginFailed": "Fallo al iniciar sesión. No se puede autenticar con el nombre de usuario/contraseña de la configuración del servidor para el inicio de sesión de Linux PAM.", "pveLoginFailed": "Fallo al iniciar sesión. No se puede autenticar con el nombre de usuario/contraseña de la configuración del servidor para el inicio de sesión de Linux PAM.",

View File

@@ -132,6 +132,7 @@
"preview": "Aperçu", "preview": "Aperçu",
"privateKey": "Clé privée", "privateKey": "Clé privée",
"process": "Processus", "process": "Processus",
"prune": "Élaguer",
"pushToken": "Jeton d'identification", "pushToken": "Jeton d'identification",
"pveIgnoreCertTip": "Il n'est pas recommandé de l'activer, attention aux risques de sécurité ! Si vous utilisez le certificat par défaut de PVE, vous devez activer cette option.", "pveIgnoreCertTip": "Il n'est pas recommandé de l'activer, attention aux risques de sécurité ! Si vous utilisez le certificat par défaut de PVE, vous devez activer cette option.",
"pveLoginFailed": "Échec de la connexion. Impossible d'authentifier avec le nom d'utilisateur / mot de passe de la configuration du serveur pour la connexion Linux PAM.", "pveLoginFailed": "Échec de la connexion. Impossible d'authentifier avec le nom d'utilisateur / mot de passe de la configuration du serveur pour la connexion Linux PAM.",

View File

@@ -132,6 +132,7 @@
"preview": "Pratinjau", "preview": "Pratinjau",
"privateKey": "Kunci Pribadi", "privateKey": "Kunci Pribadi",
"process": "Proses", "process": "Proses",
"prune": "Pangkas",
"pushToken": "Dorong token", "pushToken": "Dorong token",
"pveIgnoreCertTip": "Tidak disarankan untuk diaktifkan, waspadai risiko keamanan! Jika Anda menggunakan sertifikat default dari PVE, Anda perlu mengaktifkan opsi ini.", "pveIgnoreCertTip": "Tidak disarankan untuk diaktifkan, waspadai risiko keamanan! Jika Anda menggunakan sertifikat default dari PVE, Anda perlu mengaktifkan opsi ini.",
"pveLoginFailed": "Login gagal. Tidak dapat mengautentikasi dengan nama pengguna/kata sandi dari konfigurasi server untuk login Linux PAM.", "pveLoginFailed": "Login gagal. Tidak dapat mengautentikasi dengan nama pengguna/kata sandi dari konfigurasi server untuk login Linux PAM.",

View File

@@ -132,6 +132,7 @@
"preview": "プレビュー", "preview": "プレビュー",
"privateKey": "秘密鍵", "privateKey": "秘密鍵",
"process": "プロセス", "process": "プロセス",
"prune": "剪定する",
"pushToken": "プッシュトークン", "pushToken": "プッシュトークン",
"pveIgnoreCertTip": "オプションを有効にすることは推奨されません、セキュリティリスクに注意してくださいPVEのデフォルト証明書を使用している場合は、このオプションを有効にする必要があります。", "pveIgnoreCertTip": "オプションを有効にすることは推奨されません、セキュリティリスクに注意してくださいPVEのデフォルト証明書を使用している場合は、このオプションを有効にする必要があります。",
"pveLoginFailed": "ログインに失敗しました。Linux PAMログインのためにサーバー構成からのユーザー名/パスワードで認証できません。", "pveLoginFailed": "ログインに失敗しました。Linux PAMログインのためにサーバー構成からのユーザー名/パスワードで認証できません。",

View File

@@ -132,6 +132,7 @@
"preview": "Voorbeeld", "preview": "Voorbeeld",
"privateKey": "Privésleutel", "privateKey": "Privésleutel",
"process": "Proces", "process": "Proces",
"prune": "Snoeien",
"pushToken": "Push-token", "pushToken": "Push-token",
"pveIgnoreCertTip": "Niet aanbevolen om in te schakelen, let op beveiligingsrisico's! Als u de standaardcertificaat van PVE gebruikt, moet u deze optie inschakelen.", "pveIgnoreCertTip": "Niet aanbevolen om in te schakelen, let op beveiligingsrisico's! Als u de standaardcertificaat van PVE gebruikt, moet u deze optie inschakelen.",
"pveLoginFailed": "Aanmelden mislukt. Kan niet authenticeren met gebruikersnaam/wachtwoord van serverconfiguratie voor Linux PAM-login.", "pveLoginFailed": "Aanmelden mislukt. Kan niet authenticeren met gebruikersnaam/wachtwoord van serverconfiguratie voor Linux PAM-login.",

View File

@@ -132,6 +132,7 @@
"preview": "Pré-visualização", "preview": "Pré-visualização",
"privateKey": "Chave privada", "privateKey": "Chave privada",
"process": "Processo", "process": "Processo",
"prune": "Podar",
"pushToken": "Token de notificação push", "pushToken": "Token de notificação push",
"pveIgnoreCertTip": "Não recomendado para ativar, cuidado com os riscos de segurança! Se estiver usando o certificado padrão do PVE, você precisa habilitar esta opção.", "pveIgnoreCertTip": "Não recomendado para ativar, cuidado com os riscos de segurança! Se estiver usando o certificado padrão do PVE, você precisa habilitar esta opção.",
"pveLoginFailed": "Falha no login. Não é possível autenticar com o nome de usuário/senha da configuração do servidor para login no Linux PAM.", "pveLoginFailed": "Falha no login. Não é possível autenticar com o nome de usuário/senha da configuração do servidor para login no Linux PAM.",

View File

@@ -132,6 +132,7 @@
"preview": "Предпросмотр", "preview": "Предпросмотр",
"privateKey": "Приватный ключ", "privateKey": "Приватный ключ",
"process": "Процесс", "process": "Процесс",
"prune": "Обрезать",
"pushToken": "Токен уведомлений", "pushToken": "Токен уведомлений",
"pveIgnoreCertTip": "Не рекомендуется включать, обратите внимание на риски безопасности! Если вы используете стандартный сертификат от PVE, вам нужно включить эту опцию.", "pveIgnoreCertTip": "Не рекомендуется включать, обратите внимание на риски безопасности! Если вы используете стандартный сертификат от PVE, вам нужно включить эту опцию.",
"pveLoginFailed": "Ошибка входа. Невозможно аутентифицироваться с помощью имени пользователя/пароля из конфигурации сервера для входа в Linux PAM.", "pveLoginFailed": "Ошибка входа. Невозможно аутентифицироваться с помощью имени пользователя/пароля из конфигурации сервера для входа в Linux PAM.",

View File

@@ -132,6 +132,7 @@
"preview": "Önizleme", "preview": "Önizleme",
"privateKey": "Özel Anahtar", "privateKey": "Özel Anahtar",
"process": "İşlem", "process": "İşlem",
"prune": "Budamak",
"pushToken": "Push belirteci", "pushToken": "Push belirteci",
"pveIgnoreCertTip": "Etkinleştirilmesi önerilmez, güvenlik risklerine dikkat edin! PVE'den varsayılan sertifikayı kullanıyorsanız, bu seçeneği etkinleştirmeniz gerekir.", "pveIgnoreCertTip": "Etkinleştirilmesi önerilmez, güvenlik risklerine dikkat edin! PVE'den varsayılan sertifikayı kullanıyorsanız, bu seçeneği etkinleştirmeniz gerekir.",
"pveLoginFailed": "Giriş başarısız. Linux PAM girişi için sunucu yapılandırmasındaki kullanıcı adı/şifre ile kimlik doğrulama yapılamadı.", "pveLoginFailed": "Giriş başarısız. Linux PAM girişi için sunucu yapılandırmasındaki kullanıcı adı/şifre ile kimlik doğrulama yapılamadı.",

View File

@@ -132,6 +132,7 @@
"preview": "Попередній перегляд", "preview": "Попередній перегляд",
"privateKey": "Приватний ключ", "privateKey": "Приватний ключ",
"process": "Процес", "process": "Процес",
"prune": "Обрізати",
"pushToken": "Надіслати токен", "pushToken": "Надіслати токен",
"pveIgnoreCertTip": "Не рекомендується включати, будьте обережні з ризиками безпеки! Якщо ви використовуєте стандартний сертифікат від PVE, вам потрібно увімкнути цю опцію.", "pveIgnoreCertTip": "Не рекомендується включати, будьте обережні з ризиками безпеки! Якщо ви використовуєте стандартний сертифікат від PVE, вам потрібно увімкнути цю опцію.",
"pveLoginFailed": "Не вдалося увійти. Неможливо пройти аутентифікацію за допомогою імені користувача/пароля з конфігурації сервера для входу Linux PAM.", "pveLoginFailed": "Не вдалося увійти. Неможливо пройти аутентифікацію за допомогою імені користувача/пароля з конфігурації сервера для входу Linux PAM.",

View File

@@ -132,6 +132,7 @@
"preview": "预览", "preview": "预览",
"privateKey": "私钥", "privateKey": "私钥",
"process": "进程", "process": "进程",
"prune": "修剪",
"pushToken": "消息推送 Token", "pushToken": "消息推送 Token",
"pveIgnoreCertTip": "不推荐开启,注意安全隐患!如果你使用的 PVE 默认证书,需要开启该选项", "pveIgnoreCertTip": "不推荐开启,注意安全隐患!如果你使用的 PVE 默认证书,需要开启该选项",
"pveLoginFailed": "登录失败。无法使用服务器配置内的用户/密码,以 Linux PAM 方式登录。", "pveLoginFailed": "登录失败。无法使用服务器配置内的用户/密码,以 Linux PAM 方式登录。",

View File

@@ -132,6 +132,7 @@
"preview": "預覽", "preview": "預覽",
"privateKey": "私鑰", "privateKey": "私鑰",
"process": "行程", "process": "行程",
"prune": "修剪",
"pushToken": "消息推送 Token", "pushToken": "消息推送 Token",
"pveIgnoreCertTip": "不建議啟用,請注意安全風險!如果您使用的是 PVE 的默認證書,則需要啟用此選項。", "pveIgnoreCertTip": "不建議啟用,請注意安全風險!如果您使用的是 PVE 的默認證書,則需要啟用此選項。",
"pveLoginFailed": "登錄失敗。無法使用伺服器配置中的使用者名稱/密碼以 Linux PAM 方式登錄。", "pveLoginFailed": "登錄失敗。無法使用伺服器配置中的使用者名稱/密碼以 Linux PAM 方式登錄。",

View File

@@ -1,591 +0,0 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/route.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/model/server/server_private_info.dart';
import 'package:server_box/data/provider/container.dart';
import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/ssh/page/page.dart';
class ContainerPage extends StatefulWidget {
final SpiRequiredArgs args;
const ContainerPage({required this.args, super.key});
@override
State<ContainerPage> createState() => _ContainerPageState();
static const route = AppRouteArg(
page: ContainerPage.new,
path: '/container',
);
}
class _ContainerPageState extends State<ContainerPage> {
final _textController = TextEditingController();
late final _container = ContainerProvider(
client: widget.args.spi.server?.value.client,
userName: widget.args.spi.user,
hostId: widget.args.spi.id,
context: context,
);
late Size _size;
@override
void dispose() {
super.dispose();
_textController.dispose();
_container.dispose();
}
@override
void initState() {
super.initState();
_initAutoRefresh();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_size = MediaQuery.of(context).size;
}
@override
Widget build(BuildContext context) {
return Consumer<ContainerProvider>(
builder: (_, _, _) {
return Scaffold(
appBar: CustomAppBar(
centerTitle: true,
title: TwoLineText(up: l10n.container, down: widget.args.spi.name),
actions: [
IconButton(
onPressed: () => context.showLoadingDialog(fn: () => _container.refresh()),
icon: const Icon(Icons.refresh),
)
],
),
body: _buildMain(),
floatingActionButton: _container.error == null ? _buildFAB() : null,
);
},
);
}
Widget _buildFAB() {
return FloatingActionButton(
onPressed: () async => await _showAddFAB(),
child: const Icon(Icons.add),
);
}
Widget _buildMain() {
if (_container.error != null && _container.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(_container.error.toString()),
),
const Spacer(),
_buildEditHost(),
_buildSwitchProvider(),
UIs.height13,
],
),
);
}
if (_container.items == null || _container.images == null) {
return UIs.centerLoading;
}
return ListView(
padding: const EdgeInsets.only(left: 13, right: 13, top: 13, bottom: 37),
children: <Widget>[
_buildLoading(),
_buildVersion(),
_buildPs(),
_buildImage(),
_buildEditHost(),
_buildSwitchProvider(),
],
);
}
Widget _buildImage() {
return CardX(
child: ExpandTile(
title: Text(l10n.imagesList),
subtitle: Text(
l10n.dockerImagesFmt(_container.images!.length),
style: UIs.textGrey,
),
initiallyExpanded: (_container.images?.length ?? 0) <= 3,
children: _container.images?.map(_buildImageItem).toList() ?? [],
));
}
Widget _buildImageItem(ContainerImg e) {
return ListTile(
title: Text(e.repository ?? l10n.unknown),
subtitle: Text('${e.tag} - ${e.sizeMB}', style: UIs.textGrey),
trailing: IconButton(
padding: EdgeInsets.zero,
alignment: Alignment.centerRight,
icon: const Icon(Icons.delete),
onPressed: () => _showImageRmDialog(e),
),
);
}
Widget _buildLoading() {
if (_container.runLog == null) return UIs.placeholder;
return Padding(
padding: const EdgeInsets.all(17),
child: Column(
children: [
const Center(
child: CircularProgressIndicator(),
),
UIs.height13,
Text(_container.runLog ?? '...'),
],
),
);
}
Widget _buildVersion() {
return CardX(
child: Padding(
padding: const EdgeInsets.all(17),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_container.type.name.capitalize),
Text(_container.version ?? l10n.unknown),
],
),
));
}
Widget _buildPs() {
final items = _container.items;
if (items == null) return UIs.placeholder;
return Column(
children: items.map(_buildPsItem).toList(),
);
}
Widget _buildPsItem(ContainerPs item) {
return CardX(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 13, 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.running ? l10n.running : l10n.stopped,
final DockerPs ps => ps.state,
}}',
style: UIs.text13Grey,
),
_buildPsItemStats(item),
],
),
),
);
}
Widget _buildPsItemStats(ContainerPs item) {
if (item.cpu == null || item.mem == null) return UIs.placeholder;
return Column(
children: [
UIs.height13,
Row(
children: [
_buildPsItemStatsItem('CPU', item.cpu, Icons.memory),
UIs.width13,
_buildPsItemStatsItem('Net', item.net, Icons.network_cell),
],
),
Row(
children: [
_buildPsItemStatsItem('Mem', item.mem, Icons.settings_input_component),
UIs.width13,
_buildPsItemStatsItem('Disk', item.disk, Icons.storage),
],
),
],
);
}
Widget _buildPsItemStatsItem(String title, String? value, IconData icon) {
return SizedBox(
width: _size.width / 2 - 41,
child: Column(
children: [
Row(
children: [
Icon(icon, size: 12, color: Colors.grey),
UIs.width7,
Text(value ?? l10n.unknown, style: UIs.text11Grey),
],
)
],
),
);
}
Widget _buildMoreBtn(ContainerPs dItem) {
return PopupMenu(
items: ContainerMenu.items(dItem.running).map((e) => PopMenu.build(e, e.icon, e.toStr)).toList(),
onSelected: (item) => _onTapMoreBtn(item, dItem),
);
}
// String _buildPsCardSubtitle(List<ContainerPs> running) {
// final runningCount = running.where((element) => element.running).length;
// final stoped = running.length - runningCount;
// if (stoped == 0) {
// return l10n.dockerStatusRunningFmt(runningCount);
// }
// return l10n.dockerStatusRunningAndStoppedFmt(runningCount, stoped);
// }
Widget _buildEditHost() {
final children = <Widget>[];
final emptyImgs = _container.images?.isEmpty ?? false;
final emptyPs = _container.items?.isEmpty ?? false;
if (emptyPs && emptyImgs) {
children.add(Padding(
padding: const EdgeInsets.fromLTRB(17, 17, 17, 0),
child: SimpleMarkdown(data: l10n.dockerEmptyRunningItems),
));
}
children.add(
TextButton(
onPressed: _showEditHostDialog,
child: Text('${libL10n.edit} DOCKER_HOST'),
),
);
return CardX(
child: Column(
children: children,
));
}
Widget _buildSwitchProvider() {
late final Widget child;
if (_container.type == ContainerType.podman) {
child = TextButton(
onPressed: () {
_container.setType(ContainerType.docker);
},
child: Text(l10n.switchTo('Docker')),
);
} else {
child = TextButton(
onPressed: () {
_container.setType(ContainerType.podman);
},
child: Text(l10n.switchTo('Podman')),
);
}
return CardX(child: child);
}
Future<void> _showAddFAB() async {
final imageCtrl = TextEditingController();
final nameCtrl = TextEditingController();
final argsCtrl = TextEditingController();
await context.showRoundDialog(
title: l10n.newContainer,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Input(
autoFocus: true,
type: TextInputType.text,
label: l10n.image,
hint: 'xxx:1.1',
controller: imageCtrl,
suggestion: false,
),
Input(
type: TextInputType.text,
controller: nameCtrl,
label: libL10n.name,
hint: 'xxx',
suggestion: false,
),
Input(
type: TextInputType.text,
controller: argsCtrl,
label: l10n.extraArgs,
hint: '-p 2222:22 -v ~/.xxx/:/xxx',
suggestion: false,
),
],
),
actions: Btn.ok(onTap: () async {
context.pop();
await _showAddCmdPreview(
_buildAddCmd(
imageCtrl.text.trim(),
nameCtrl.text.trim(),
argsCtrl.text.trim(),
),
);
}).toList);
}
Future<void> _showAddCmdPreview(String cmd) async {
await context.showRoundDialog(
title: l10n.preview,
child: Text(cmd),
actions: [
TextButton(
onPressed: () => context.pop(),
child: Text(libL10n.cancel),
),
TextButton(
onPressed: () async {
context.pop();
final (result, err) = await context.showLoadingDialog(
fn: () => _container.run(cmd),
);
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(
title: libL10n.error,
child: Text(e.toString()),
);
}
},
child: Text(l10n.run),
)
],
);
}
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';
}
Future<void> _showEditHostDialog() async {
final id = widget.args.spi.id;
final host = Stores.container.fetch(id);
final ctrl = TextEditingController(text: host);
await context.showRoundDialog(
title: libL10n.edit,
child: Input(
maxLines: 2,
controller: ctrl,
onSubmitted: _onSaveDockerHost,
hint: 'unix:///run/user/1000/docker.sock',
suggestion: false,
),
actions: Btn.ok(onTap: () => _onSaveDockerHost(ctrl.text)).toList,
);
}
void _onSaveDockerHost(String val) {
context.pop();
Stores.container.put(widget.args.spi.id, val.trim());
_container.refresh();
}
void _showImageRmDialog(ContainerImg e) {
context.showRoundDialog(
title: libL10n.attention,
child: Text(
libL10n.askContinue('${libL10n.delete} Image(${e.repository})'),
),
actions: Btn.ok(
onTap: () async {
context.pop();
final result = await _container.run('rmi ${e.id} -f');
if (result != null) {
context.showSnackBar(result.message ?? 'null');
}
},
red: true,
).toList,
);
}
void _onTapMoreBtn(ContainerMenu item, ContainerPs dItem) async {
final id = dItem.id;
if (id == null) {
context.showSnackBar('Id is null');
return;
}
switch (item) {
case ContainerMenu.rm:
var force = false;
context.showRoundDialog(
title: libL10n.attention,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(libL10n.askContinue(
'${libL10n.delete} Container(${dItem.name})',
)),
UIs.height13,
Row(
children: [
StatefulBuilder(builder: (_, setState) {
return Checkbox(
value: force,
onChanged: (val) => setState(
() => force = val ?? false,
),
);
}),
Text(l10n.force),
],
)
],
),
actions: Btn.ok(onTap: () async {
context.pop();
final (result, err) = await context.showLoadingDialog(
fn: () => _container.delete(id, force),
);
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(
title: libL10n.error,
child: Text(e ?? 'null'),
);
}
}).toList,
);
break;
case ContainerMenu.start:
final (result, err) = await context.showLoadingDialog(
fn: () => _container.start(id),
);
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(
title: libL10n.error,
child: Text(e ?? 'null'),
);
}
break;
case ContainerMenu.stop:
final (result, err) = await context.showLoadingDialog(
fn: () => _container.stop(id),
);
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(
title: libL10n.error,
child: Text(e ?? 'null'),
);
}
break;
case ContainerMenu.restart:
final (result, err) = await context.showLoadingDialog(
fn: () => _container.restart(id),
);
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(
title: libL10n.error,
child: Text(e ?? 'null'),
);
}
break;
case ContainerMenu.logs:
final args = SshPageArgs(
spi: widget.args.spi,
initCmd: '${switch (_container.type) {
ContainerType.podman => 'podman',
ContainerType.docker => 'docker',
}} logs -f --tail 100 ${dItem.id}',
);
SSHPage.route.go(context, args);
break;
case ContainerMenu.terminal:
final args = SshPageArgs(
spi: widget.args.spi,
initCmd: '${switch (_container.type) {
ContainerType.podman => 'podman',
ContainerType.docker => 'docker',
}} exec -it ${dItem.id} sh',
);
SSHPage.route.go(context, args);
break;
// case DockerMenuType.stats:
// showRoundDialog(
// context: context,
// title: Text(l10n.stats),
// child: Text(
// 'CPU: ${dItem.cpu}\n'
// 'Mem: ${dItem.mem}\n'
// 'Net: ${dItem.net}\n'
// 'Block: ${dItem.disk}',
// ),
// actions: [
// TextButton(
// onPressed: () => context.pop(),
// child: Text(l10n.ok),
// ),
// ],
// );
// break;
}
}
void _initAutoRefresh() {
if (Stores.setting.contaienrAutoRefresh.fetch()) {
Timer.periodic(
Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()),
(timer) {
if (mounted) {
_container.refresh(isAuto: true);
} else {
timer.cancel();
}
},
);
}
}
}

View File

@@ -0,0 +1,232 @@
part of 'container.dart';
extension on _ContainerPageState {
Future<void> _showAddFAB() async {
final imageCtrl = TextEditingController();
final nameCtrl = TextEditingController();
final argsCtrl = TextEditingController();
await context.showRoundDialog(
title: l10n.newContainer,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Input(
autoFocus: true,
type: TextInputType.text,
label: l10n.image,
hint: 'xxx:1.1',
controller: imageCtrl,
suggestion: false,
),
Input(
type: TextInputType.text,
controller: nameCtrl,
label: libL10n.name,
hint: 'xxx',
suggestion: false,
),
Input(
type: TextInputType.text,
controller: argsCtrl,
label: l10n.extraArgs,
hint: '-p 2222:22 -v ~/.xxx/:/xxx',
suggestion: false,
),
],
),
actions: Btn.ok(
onTap: () async {
context.pop();
await _showAddCmdPreview(
_buildAddCmd(imageCtrl.text.trim(), nameCtrl.text.trim(), argsCtrl.text.trim()),
);
},
).toList,
);
}
Future<void> _showPruneDialog({
required String title,
String? message,
required Future<ContainerErr?> Function() onConfirm,
}) async {
await context.showRoundDialog(
title: title,
child: Text(message ?? libL10n.askContinue('${l10n.prune} $title')),
actions: Btn.ok(
onTap: () async {
context.pop();
final (result, err) = await context.showLoadingDialog(fn: onConfirm);
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e.toString()));
} else {
context.showSnackBar(libL10n.success);
}
},
red: true,
).toList,
);
}
Future<void> _showAddCmdPreview(String cmd) async {
await context.showRoundDialog(
title: l10n.preview,
child: Text(cmd),
actions: [
TextButton(onPressed: () => context.pop(), child: Text(libL10n.cancel)),
TextButton(
onPressed: () async {
context.pop();
final (result, err) = await context.showLoadingDialog(fn: () => _container.run(cmd));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e.toString()));
}
},
child: Text(l10n.run),
),
],
);
}
Future<void> _showEditHostDialog() async {
final id = widget.args.spi.id;
final host = Stores.container.fetch(id);
final ctrl = TextEditingController(text: host);
await context.showRoundDialog(
title: libL10n.edit,
child: Input(
maxLines: 2,
controller: ctrl,
onSubmitted: _onSaveDockerHost,
hint: 'unix:///run/user/1000/docker.sock',
suggestion: false,
),
actions: Btn.ok(onTap: () => _onSaveDockerHost(ctrl.text)).toList,
);
}
void _onSaveDockerHost(String val) {
context.pop();
Stores.container.put(widget.args.spi.id, val.trim());
_container.refresh();
}
void _showImageRmDialog(ContainerImg e) {
context.showRoundDialog(
title: libL10n.attention,
child: Text(libL10n.askContinue('${libL10n.delete} Image(${e.repository})')),
actions: Btn.ok(
onTap: () async {
context.pop();
final result = await _container.run('rmi ${e.id} -f');
if (result != null) {
context.showSnackBar(result.message ?? 'null');
}
},
red: true,
).toList,
);
}
void _onTapMoreBtn(ContainerMenu item, ContainerPs dItem) async {
final id = dItem.id;
if (id == null) {
context.showSnackBar('Id is null');
return;
}
switch (item) {
case ContainerMenu.rm:
var force = false;
context.showRoundDialog(
title: libL10n.attention,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(libL10n.askContinue('${libL10n.delete} Container(${dItem.name})')),
UIs.height13,
Row(
children: [
StatefulBuilder(
builder: (_, setState) {
return Checkbox(value: force, onChanged: (val) => setState(() => force = val ?? false));
},
),
Text(l10n.force),
],
),
],
),
actions: Btn.ok(
onTap: () async {
context.pop();
final (result, err) = await context.showLoadingDialog(fn: () => _container.delete(id, force));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null'));
}
},
).toList,
);
break;
case ContainerMenu.start:
final (result, err) = await context.showLoadingDialog(fn: () => _container.start(id));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null'));
}
break;
case ContainerMenu.stop:
final (result, err) = await context.showLoadingDialog(fn: () => _container.stop(id));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null'));
}
break;
case ContainerMenu.restart:
final (result, err) = await context.showLoadingDialog(fn: () => _container.restart(id));
if (err != null || result != null) {
final e = result?.message ?? err?.toString();
context.showRoundDialog(title: libL10n.error, child: Text(e ?? 'null'));
}
break;
case ContainerMenu.logs:
final args = SshPageArgs(
spi: widget.args.spi,
initCmd:
'${switch (_container.type) {
ContainerType.podman => 'podman',
ContainerType.docker => 'docker',
}} logs -f --tail 100 ${dItem.id}',
);
SSHPage.route.go(context, args);
break;
case ContainerMenu.terminal:
final args = SshPageArgs(
spi: widget.args.spi,
initCmd:
'${switch (_container.type) {
ContainerType.podman => 'podman',
ContainerType.docker => 'docker',
}} exec -it ${dItem.id} sh',
);
SSHPage.route.go(context, args);
break;
}
}
void _initAutoRefresh() {
if (Stores.setting.containerAutoRefresh.fetch()) {
Timer.periodic(Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), (timer) {
if (mounted) {
_container.refresh(isAuto: true);
} else {
timer.cancel();
}
});
}
}
}

View File

@@ -0,0 +1,370 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:provider/provider.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/model/server/server_private_info.dart';
import 'package:server_box/data/provider/container.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 StatefulWidget {
final SpiRequiredArgs args;
const ContainerPage({required this.args, super.key});
@override
State<ContainerPage> createState() => _ContainerPageState();
static const route = AppRouteArg(page: ContainerPage.new, path: '/container');
}
class _ContainerPageState extends State<ContainerPage> {
final _textController = TextEditingController();
late final _container = ContainerProvider(
client: widget.args.spi.server?.value.client,
userName: widget.args.spi.user,
hostId: widget.args.spi.id,
context: context,
);
@override
void dispose() {
super.dispose();
_textController.dispose();
_container.dispose();
}
@override
void initState() {
super.initState();
_initAutoRefresh();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => _container,
builder: (_, _) => Consumer<ContainerProvider>(
builder: (_, _, _) {
return Scaffold(
appBar: _buildAppBar,
body: _buildMain,
floatingActionButton: _container.error == null ? _buildFAB : null,
);
},
),
);
}
CustomAppBar get _buildAppBar {
return CustomAppBar(
centerTitle: true,
title: TwoLineText(up: l10n.container, down: widget.args.spi.name),
actions: [
IconButton(
onPressed: () => context.showLoadingDialog(fn: () => _container.refresh()),
icon: const Icon(Icons.refresh),
),
],
);
}
Widget get _buildFAB {
return FloatingActionButton(onPressed: () async => await _showAddFAB(), child: const Icon(Icons.add));
}
Widget get _buildMain {
if (_container.error != null && _container.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(_container.error.toString()),
),
const Spacer(),
UIs.height13,
_buildSettingsBtns,
],
),
);
}
if (_container.items == null || _container.images == null) {
return UIs.centerLoading;
}
return SafeArea(
child: AutoMultiList(
children: <Widget>[
_buildLoading(),
_buildVersion(),
_buildPs(),
_buildImage(),
_buildEmptyStateMessage(),
_buildPruneBtns,
_buildSettingsBtns,
],
),
);
}
Widget _buildEmptyStateMessage() {
final emptyImgs = _container.images?.isEmpty ?? true;
final emptyPs = _container.items?.isEmpty ?? true;
if (emptyPs && emptyImgs && _container.runLog == null) {
return CardX(
child: Padding(
padding: const EdgeInsets.fromLTRB(17, 17, 17, 7),
child: SimpleMarkdown(data: l10n.dockerEmptyRunningItems),
),
);
}
return UIs.placeholder;
}
Widget _buildImage() {
return ExpandTile(
leading: const Icon(MingCute.clapperboard_line),
title: Text(l10n.imagesList),
subtitle: Text(l10n.dockerImagesFmt(_container.images!.length), style: UIs.textGrey),
initiallyExpanded: (_container.images?.length ?? 0) <= 3,
children: _container.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: IconButton(
padding: EdgeInsets.zero,
icon: const Icon(Icons.delete),
onPressed: () => _showImageRmDialog(e),
),
);
}
Widget _buildLoading() {
if (_container.runLog == null) return UIs.placeholder;
return Padding(
padding: const EdgeInsets.all(17),
child: Column(
children: [
const Center(child: CircularProgressIndicator()),
UIs.height13,
Text(_container.runLog ?? '...'),
],
),
);
}
Widget _buildVersion() {
return CardX(
child: Padding(
padding: const EdgeInsets.all(17),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(_container.type.name.capitalize), Text(_container.version ?? l10n.unknown)],
),
),
);
}
Widget _buildPs() {
final items = _container.items;
if (items == null) return UIs.placeholder;
final running = items.where((e) => e.running).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.running ? l10n.running : l10n.stopped,
final DockerPs ps => ps.state,
}}',
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 - 41;
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(
children: [
Icon(icon, size: 12, color: Colors.grey),
UIs.width7,
Text(value ?? l10n.unknown, style: UIs.text11Grey),
],
),
],
),
);
}
Widget _buildMoreBtn(ContainerPs dItem) {
return PopupMenu(
items: ContainerMenu.items(dItem.running).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;
return ListTile(
onTap: () async {
await _showPruneDialog(
title: title,
message: type.tip,
onConfirm: switch (type) {
_PruneTypes.images => _container.pruneImages,
_PruneTypes.containers => () => _container.pruneContainers(),
_PruneTypes.volumes => _container.pruneVolumes,
_PruneTypes.system => _container.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;
return ExpandTile(
leading: const Icon(Icons.settings),
title: Text(libL10n.setting),
initiallyExpanded: _container.error != null,
children: _SettingsMenuItems.values.map(_buildSettingTile).toList(),
).cardx;
}
Widget _buildSettingTile(_SettingsMenuItems item) {
final String title;
switch (item) {
case _SettingsMenuItems.editDockerHost:
title = '${libL10n.edit} DOCKER_HOST';
break;
case _SettingsMenuItems.switchProvider:
title = _container.type == ContainerType.podman ? l10n.switchTo('Docker') : l10n.switchTo('Podman');
break;
}
return ListTile(
onTap: () {
switch (item) {
case _SettingsMenuItems.editDockerHost:
_showEditHostDialog();
break;
case _SettingsMenuItems.switchProvider:
_container.setType(
_container.type == ContainerType.docker ? ContainerType.podman : ContainerType.docker,
);
break;
}
},
title: Text(title),
trailing: const Icon(Icons.keyboard_arrow_right),
);
}
}

View File

@@ -0,0 +1,18 @@
part of 'container.dart';
enum _SettingsMenuItems { editDockerHost, switchProvider }
enum _PruneTypes {
images,
containers,
volumes,
system;
String? get tip {
return switch (this) {
_PruneTypes.system =>
'This will remove all unused data, including images, containers, volumes, and networks.',
_ => null,
};
}
}

View File

@@ -12,7 +12,7 @@ import 'package:server_box/data/model/server/snippet.dart';
import 'package:server_box/data/provider/server.dart'; import 'package:server_box/data/provider/server.dart';
import 'package:server_box/data/provider/snippet.dart'; import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/store.dart'; import 'package:server_box/data/res/store.dart';
import 'package:server_box/view/page/container.dart'; import 'package:server_box/view/page/container/container.dart';
import 'package:server_box/view/page/iperf.dart'; import 'package:server_box/view/page/iperf.dart';
import 'package:server_box/view/page/process.dart'; import 'package:server_box/view/page/process.dart';
import 'package:server_box/view/page/ssh/page/page.dart'; import 'package:server_box/view/page/ssh/page/page.dart';