import 'dart:async'; import 'dart:convert'; import 'package:dartssh2/dartssh2.dart'; import 'package:fl_lib/fl_lib.dart'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:server_box/core/extension/ssh_client.dart'; import 'package:server_box/data/model/app/error.dart'; import 'package:server_box/data/model/app/scripts/script_consts.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/res/store.dart'; part 'container.freezed.dart'; part 'container.g.dart'; final _dockerNotFound = RegExp(r"command not found|Unknown command|Command '\w+' not found"); @freezed abstract class ContainerState with _$ContainerState { const factory ContainerState({ @Default(null) List? items, @Default(null) List? images, @Default(null) String? version, @Default(null) ContainerErr? error, @Default(null) String? runLog, @Default(ContainerType.docker) ContainerType type, @Default(false) bool isBusy, }) = _ContainerState; } @riverpod class ContainerNotifier extends _$ContainerNotifier { var sudoCompleter = Completer(); @override ContainerState build(SSHClient? client, String userName, String hostId, BuildContext context) { this.client = client; this.userName = userName; this.hostId = hostId; this.context = context; final type = Stores.container.getType(hostId); final initialState = ContainerState(type: type); // Async initialization Future.microtask(() => refresh()); return initialState; } Future setType(ContainerType type) async { state = state.copyWith( type: type, error: null, runLog: null, items: null, images: null, version: null, ); Stores.container.setType(type, hostId); sudoCompleter = Completer(); await refresh(); } void _requiresSudo() async { /// Podman is rootless if (state.type == ContainerType.podman) return sudoCompleter.complete(false); if (!Stores.setting.containerTrySudo.fetch()) { return sudoCompleter.complete(false); } final res = await client?.run(_wrap(ContainerCmdType.images.exec(state.type))); if (res?.string.toLowerCase().contains('permission denied') ?? false) { return sudoCompleter.complete(true); } return sudoCompleter.complete(false); } Future refresh({bool isAuto = false}) async { if (state.isBusy) return; state = state.copyWith(isBusy: true); if (!sudoCompleter.isCompleted) _requiresSudo(); final sudo = await sudoCompleter.future; /// If sudo is required and auto refresh is enabled, skip the refresh. /// Or this will ask for pwd again and again. if (sudo && isAuto) { state = state.copyWith(isBusy: false); return; } final includeStats = Stores.setting.containerParseStat.fetch(); var raw = ''; final cmd = _wrap(ContainerCmdType.execAll(state.type, sudo: sudo, includeStats: includeStats)); final code = await client?.execWithPwd( cmd, context: context, onStdout: (data, _) => raw = '$raw$data', id: hostId, ); state = state.copyWith(isBusy: false); if (!context.mounted) return; /// Code 127 means command not found if (code == 127 || raw.contains(_dockerNotFound)) { state = state.copyWith(error: ContainerErr(type: ContainerErrType.notInstalled)); return; } // Check result segments count final segments = raw.split(ScriptConstants.separator); if (segments.length != ContainerCmdType.values.length) { state = state.copyWith( error: ContainerErr( type: ContainerErrType.segmentsNotMatch, message: 'Container segments: ${segments.length}', ), ); Loggers.app.warning('Container segments: ${segments.length}\n$raw'); return; } // Parse version final verRaw = ContainerCmdType.version.find(segments); try { final version = json.decode(verRaw)['Client']['Version']; state = state.copyWith(version: version, error: null); } catch (e, trace) { state = state.copyWith( error: ContainerErr(type: ContainerErrType.invalidVersion, message: '$e'), ); Loggers.app.warning('Container version failed', e, trace); } // Parse ps final psRaw = ContainerCmdType.ps.find(segments); try { final lines = psRaw.split('\n'); if (state.type == ContainerType.docker) { /// Due to the fetched data is not in json format, skip table header lines.removeWhere((element) => element.contains('CONTAINER ID')); } lines.removeWhere((element) => element.isEmpty); final items = lines.map((e) => ContainerPs.fromRaw(e, state.type)).toList(); state = state.copyWith(items: items); } catch (e, trace) { state = state.copyWith( error: ContainerErr(type: ContainerErrType.parsePs, message: '$e'), ); Loggers.app.warning('Container ps failed', e, trace); } // Parse images final imageRaw = ContainerCmdType.images.find(segments).trim(); final isEntireJson = imageRaw.startsWith('[') && imageRaw.endsWith(']'); try { List images; if (isEntireJson) { images = (json.decode(imageRaw) as List) .map((e) => ContainerImg.fromRawJson(json.encode(e), state.type)) .toList(); } else { final lines = imageRaw.split('\n'); lines.removeWhere((element) => element.isEmpty); images = lines.map((e) => ContainerImg.fromRawJson(e, state.type)).toList(); } state = state.copyWith(images: images); } catch (e, trace) { state = state.copyWith( error: ContainerErr(type: ContainerErrType.parseImages, message: '$e'), ); Loggers.app.warning('Container images failed', e, trace); } // Parse stats final statsRaw = ContainerCmdType.stats.find(segments); try { final statsLines = statsRaw.split('\n'); statsLines.removeWhere((element) => element.isEmpty); for (var item in state.items!) { final id = item.id; if (id == null) continue; final statsLine = statsLines.firstWhereOrNull( /// Use 5 characters to match the container id, possibility of mismatch /// is very low. (element) => element.contains(id.substring(0, 5)), ); if (statsLine == null) continue; item.parseStats(statsLine); } } catch (e, trace) { state = state.copyWith( error: ContainerErr(type: ContainerErrType.parseStats, message: '$e'), ); Loggers.app.warning('Parse docker stats: $statsRaw', e, trace); } } Future stop(String id) async => await run('stop $id'); Future start(String id) async => await run('start $id'); Future delete(String id, bool force) async { if (force) { return await run('rm -f $id'); } return await run('rm $id'); } Future restart(String id) async => await run('restart $id'); Future pruneImages({bool all = true}) async { final cmd = 'image prune${all ? " -a" : ""} -f'; return await run(cmd); } Future pruneContainers() async { return await run('container prune -f'); } Future pruneVolumes() async { return await run('volume prune -f'); } Future pruneSystem() async { return await run('system prune -a -f --volumes'); } Future run(String cmd, {bool autoRefresh = true}) async { cmd = switch (state.type) { ContainerType.docker => 'docker $cmd', ContainerType.podman => 'podman $cmd', }; state = state.copyWith(runLog: ''); final errs = []; final code = await client?.execWithPwd( _wrap((await sudoCompleter.future) ? 'sudo -S $cmd' : cmd), context: context, onStdout: (data, _) { state = state.copyWith(runLog: '${state.runLog}$data'); }, onStderr: (data, _) => errs.add(data), id: hostId, ); state = state.copyWith(runLog: null); if (code != 0) { return ContainerErr(type: ContainerErrType.unknown, message: errs.join('\n').trim()); } if (autoRefresh) await refresh(); return null; } /// wrap cmd with `docker host` String _wrap(String cmd) { final dockerHost = Stores.container.fetch(hostId); cmd = 'export LANG=en_US.UTF-8 && $cmd'; final noDockerHost = dockerHost?.isEmpty ?? true; if (!noDockerHost) { cmd = 'export DOCKER_HOST=$dockerHost && $cmd'; } return cmd; } } const _jsonFmt = '--format "{{json .}}"'; enum ContainerCmdType { version, ps, stats, images // No specific commands needed for prune actions as they are simple // and don't require splitting output with ScriptConstants.separator ; String exec(ContainerType type, {bool sudo = false, bool includeStats = false}) { final prefix = sudo ? 'sudo -S ${type.name}' : type.name; return switch (this) { ContainerCmdType.version => '$prefix version $_jsonFmt', ContainerCmdType.ps => switch (type) { /// TODO: Rollback to json format when permformance recovers. /// Use [_jsonFmt] in Docker will cause the operation to slow down. ContainerType.docker => '$prefix ps -a --format "table {{printf \\"' '%-15.15s ' '%-30.30s ' '${"%-50.50s " * 2}\\"' ' .ID .Status .Names .Image}}"', ContainerType.podman => '$prefix ps -a $_jsonFmt', }, ContainerCmdType.stats => includeStats ? '$prefix stats --no-stream $_jsonFmt' : 'echo PASS', ContainerCmdType.images => '$prefix image ls $_jsonFmt', }; } static String execAll(ContainerType type, {bool sudo = false, bool includeStats = false}) { return ContainerCmdType.values .map((e) => e.exec(type, sudo: sudo, includeStats: includeStats)) .join('\necho ${ScriptConstants.separator}\n'); } /// Find out the required segment from [segments] String find(List segments) { return segments[index]; } }