Files
flutter_server_box/lib/view/page/container/container.dart
2025-09-02 13:22:54 +08:00

374 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(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: <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 - 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),
);
}
}