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 createState() => _ContainerPageState(); static const route = AppRouteArg(page: ContainerPage.new, path: '/container'); } class _ContainerPageState extends ConsumerState { final _textController = TextEditingController(); late final ContainerNotifierProvider _provider; @override void dispose() { super.dispose(); _textController.dispose(); } @override void initState() { super.initState(); final serverState = ref.read(serverNotifierProvider(widget.args.spi.id)); _provider = containerNotifierProvider( 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: [ _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 - 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.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), ); } }