import 'dart:async'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:server_box/core/extension/context/locale.dart'; import 'package:server_box/data/model/server/pve.dart'; import 'package:server_box/data/model/server/server_private_info.dart'; import 'package:server_box/data/provider/pve.dart'; import 'package:server_box/data/res/store.dart'; import 'package:server_box/view/widget/percent_circle.dart'; final class PvePageArgs { final Spi spi; const PvePageArgs({required this.spi}); } final class PvePage extends ConsumerStatefulWidget { final PvePageArgs args; const PvePage({super.key, required this.args}); @override ConsumerState createState() => _PvePageState(); static const route = AppRouteArg(page: PvePage.new, path: '/pve'); } const _kHorziPadding = 11.0; final class _PvePageState extends ConsumerState { late MediaQueryData _media; Timer? _timer; late final _provider = pveNotifierProvider(widget.args.spi); late final _notifier = ref.read(_provider.notifier); @override void dispose() { super.dispose(); _timer?.cancel(); } @override void didChangeDependencies() { super.didChangeDependencies(); _media = MediaQuery.of(context); } @override void initState() { super.initState(); _initRefreshTimer(); _afterInit(); } @override Widget build(BuildContext context) { final pveState = ref.watch(_provider); // If there is an error, stop the timer if (pveState.error != null) { _timer?.cancel(); } return Scaffold( appBar: CustomAppBar( title: TwoLineText(up: 'PVE', down: widget.args.spi.name), actions: [ pveState.error == null ? UIs.placeholder : Btn.icon( icon: const Icon(Icons.refresh), onTap: () { _notifier.list(); _initRefreshTimer(); }, ), ], ), body: pveState.error != null ? Padding( padding: const EdgeInsets.all(13), child: Center(child: Text(pveState.error.toString())), ) : _buildBody(pveState.data), ); } Widget _buildBody(PveRes? data) { if (data == null) { return UIs.centerLoading; } PveResType? lastType; return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: _kHorziPadding, vertical: 7), itemCount: data.length * 2, itemBuilder: (context, index) { final item = data[index ~/ 2]; if (index % 2 == 0) { final type = switch (item) { final PveNode _ => PveResType.node, final PveQemu _ => PveResType.qemu, final PveLxc _ => PveResType.lxc, final PveStorage _ => PveResType.storage, final PveSdn _ => PveResType.sdn, }; if (type == lastType) { return UIs.placeholder; } lastType = type; return Padding( padding: const EdgeInsets.symmetric(vertical: 7), child: Align( alignment: Alignment.center, child: Text( type.toStr, style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), textAlign: TextAlign.start, ), ), ); } return switch (item) { final PveNode _ => _buildNode(item), final PveQemu _ => _buildQemu(item), final PveLxc _ => _buildLxc(item), final PveStorage _ => _buildStorage(item), final PveSdn _ => _buildSdn(item), }; }, ); } Widget _buildNode(PveNode item) { final valueAnim = AlwaysStoppedAnimation(UIs.primaryColor); return Padding( padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 13), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Text(item.node, style: UIs.text15Bold), const Spacer(), Text(item.topRight, style: UIs.text12Grey), ], ), UIs.height13, // Row( // mainAxisAlignment: MainAxisAlignment.spaceAround, // children: [ // _wrap(PercentCircle(percent: item.cpu / item.maxcpu), 3), // _wrap(PercentCircle(percent: item.mem / item.maxmem), 3), // ], // ), Row( children: [ const Icon(Icons.memory, size: 13, color: Colors.grey), UIs.width7, const Text('CPU', style: UIs.text12Grey), const Spacer(), Text('${(item.cpu * 100).toStringAsFixed(1)} %', style: UIs.text12Grey), ], ), const SizedBox(height: 3), LinearProgressIndicator(value: item.cpu / item.maxcpu, minHeight: 7, valueColor: valueAnim), UIs.height7, Row( children: [ const Icon(Icons.view_agenda, size: 13, color: Colors.grey), UIs.width7, const Text('RAM', style: UIs.text12Grey), const Spacer(), Text('${item.mem.bytes2Str} / ${item.maxmem.bytes2Str}', style: UIs.text12Grey), ], ), const SizedBox(height: 3), LinearProgressIndicator(value: item.mem / item.maxmem, minHeight: 7, valueColor: valueAnim), ], ), ).cardx; } Widget _buildQemu(PveQemu item) { if (!item.available) { return ListTile( title: Text(_wrapNodeName(item), style: UIs.text13Bold), trailing: _buildCtrlBtns(item), ).cardx; } final children = [ const SizedBox(height: 5), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ const SizedBox(width: 15), Text(_wrapNodeName(item), style: UIs.text13Bold), Text(' / ${item.summary}', style: UIs.text12Grey), const Spacer(), _buildCtrlBtns(item), UIs.width13, ], ), UIs.height7, AvgSize( totalSize: _media.size.width, padding: _kHorziPadding * 2 + 26, children: [ PercentCircle(percent: (item.cpu / item.maxcpu) * 100), PercentCircle(percent: (item.mem / item.maxmem) * 100), Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '${l10n.read}:\n${item.diskread.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center, ), const SizedBox(height: 3), Text( '${l10n.write}:\n${item.diskwrite.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center, ), ], ), Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Text('↓:\n${item.netin.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center), const SizedBox(height: 3), Text('↑:\n${item.netout.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center), ], ), ], ), const SizedBox(height: 21), ]; return Column(mainAxisSize: MainAxisSize.min, children: children).cardx; } Widget _buildLxc(PveLxc item) { if (!item.available) { return ListTile( title: Text(_wrapNodeName(item), style: UIs.text13Bold), trailing: _buildCtrlBtns(item), ).cardx; } final children = [ const SizedBox(height: 5), Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ const SizedBox(width: 15), Text(_wrapNodeName(item), style: UIs.text13Bold), Text(' / ${item.summary}', style: UIs.text12Grey), const Spacer(), _buildCtrlBtns(item), UIs.width13, ], ), UIs.height7, AvgSize( totalSize: _media.size.width, padding: _kHorziPadding * 2 + 26, children: [ PercentCircle(percent: (item.cpu / item.maxcpu) * 100), PercentCircle(percent: (item.mem / item.maxmem) * 100), Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '${l10n.read}:\n${item.diskread.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center, ), const SizedBox(height: 3), Text( '${l10n.write}:\n${item.diskwrite.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center, ), ], ), Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Text('↓:\n${item.netin.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center), const SizedBox(height: 3), Text('↑:\n${item.netout.bytes2Str}', style: UIs.text11Grey, textAlign: TextAlign.center), ], ), ], ), const SizedBox(height: 21), ]; return Column(mainAxisSize: MainAxisSize.min, children: children).cardx; } Widget _buildStorage(PveStorage item) { return Padding( padding: const EdgeInsets.all(13), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text(_wrapNodeName(item), style: UIs.text13Bold), const Spacer(), Text(item.summary, style: UIs.text11Grey), ], ), UIs.height7, KvRow(k: libL10n.content, v: item.content), KvRow(k: l10n.plugInType, v: item.plugintype), ], ), ).cardx; } Widget _buildSdn(PveSdn item) { return ListTile(title: Text(_wrapNodeName(item)), trailing: Text(item.summary)).cardx; } Widget _buildCtrlBtns(PveCtrlIface item) { const pad = EdgeInsets.symmetric(horizontal: 7, vertical: 5); if (!item.available) { return Btn.icon( icon: const Icon(Icons.play_arrow, color: Colors.grey), onTap: () => _onCtrl(l10n.start, item, () => _notifier.start(item.node, item.id)), ); } return Row( children: [ Btn.icon( icon: const Icon(Icons.stop, color: Colors.grey, size: 20), padding: pad, onTap: () => _onCtrl(l10n.stop, item, () => _notifier.stop(item.node, item.id)), ), Btn.icon( icon: const Icon(Icons.refresh, color: Colors.grey, size: 20), padding: pad, onTap: () => _onCtrl(l10n.reboot, item, () => _notifier.reboot(item.node, item.id)), ), Btn.icon( icon: const Icon(Icons.power_off, color: Colors.grey, size: 20), padding: pad, onTap: () => _onCtrl(l10n.shutdown, item, () => _notifier.shutdown(item.node, item.id)), ), ], ); } } extension on _PvePageState { void _onCtrl(String action, PveCtrlIface item, Future Function() func) async { final sure = await context.showRoundDialog( title: libL10n.attention, child: Text(libL10n.askContinue('$action ${item.id}')), actions: Btnx.okReds, ); if (sure != true) return; final (suc, err) = await context.showLoadingDialog(fn: func); if (suc == true) { context.showSnackBar(libL10n.success); } else { context.showSnackBar(err?.toString() ?? libL10n.fail); } } /// Add PveNode if only one node exists String _wrapNodeName(PveCtrlIface item) { final pveState = ref.read(_provider); if (pveState.data?.onlyOneNode ?? false) { return item.name; } return '${item.node} / ${item.name}'; } void _initRefreshTimer() { _timer?.cancel(); _timer = Timer.periodic(Duration(seconds: Stores.setting.serverStatusUpdateInterval.fetch()), (_) { if (mounted) { _notifier.list(); } }); } void _afterInit() async { // Wait for the PVE state to be connected while (mounted) { final pveState = ref.read(_provider); if (pveState.isConnected) { if (pveState.release != null && pveState.release!.compareTo('8.0') < 0) { if (mounted) { context.showSnackBar(l10n.pveVersionLow); } } break; } if (pveState.error != null) { break; // Skip if there is an error } await Future.delayed(const Duration(milliseconds: 100)); } } }