diff --git a/bricks/internal_memory/brick.yaml b/bricks/internal_memory/brick.yaml new file mode 100644 index 0000000..3aca407 --- /dev/null +++ b/bricks/internal_memory/brick.yaml @@ -0,0 +1,24 @@ +name: internal_memory +description: Adds a persisted field to local storage β€” injects the getter/setter + pair into ConnectInternalMemory, InternalMemory and AppDataManager in one go, + with JSON handling for objects and lists. + +version: 0.1.0+1 + +environment: + mason: ^0.1.1 + +vars: + function_name: + type: string + description: The field name in camelCase + prompt: "πŸ–‹οΈ What is the field name? (e.g. activeServer)" + request_object: + type: string + description: The stored type β€” String, a model, or List + prompt: "πŸ“¦ What type is stored? (e.g. ServerProfile / List / String)" + secure: + type: boolean + description: Store in the OS keystore instead of shared_preferences + default: false + prompt: "πŸ” Is this a secret (password/token)?" diff --git a/bricks/internal_memory/hooks/post_gen.dart b/bricks/internal_memory/hooks/post_gen.dart new file mode 100644 index 0000000..05333c7 --- /dev/null +++ b/bricks/internal_memory/hooks/post_gen.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:mason/mason.dart'; + +/// Adds one persisted field to local storage by injecting the same +/// getter/setter pair into all three files that have to agree: +/// +/// ConnectInternalMemory β†’ the contract +/// InternalMemory β†’ the implementation (+ its storage key) +/// AppDataManager β†’ the delegation +/// +/// Doing this by hand drifts: it's easy to add the pair to two of the three and +/// only find out at compile time, and the secure-key constant should never be +/// hand-picked. +Future run(HookContext context) async { + final logger = context.logger; + final functionName = (context.vars['function_name'] ?? '') as String; + final requestObject = (context.vars['request_object'] ?? '') as String; + final secure = (context.vars['secure'] ?? false) as bool; + + if (functionName.isEmpty || requestObject.isEmpty) { + logger.err('Both a field name and a type are required.'); + return; + } + + final pName = functionName.pascalCase; + final keyConst = '_${functionName.camelCase}Key'; + final keyValue = secure + ? _secureKeyValue(functionName) + : 'peach.${functionName.snakeCase}'; + + final isList = requestObject.startsWith('List<'); + final isString = requestObject.toLowerCase() == 'string'; + final baseType = isList + ? requestObject.substring(5, requestObject.length - 1) + : requestObject; + + const memoryDir = 'lib/Peach/memory'; + const connectPath = '$memoryDir/ConnectInternalMemory.dart'; + const memoryPath = '$memoryDir/InternalMemory.dart'; + const managerPath = 'lib/Peach/informatics/AppDataManager.dart'; + + // Model types need importing into all three files; String doesn't. + String? modelImportFor(String fromFile) => + isString ? null : _relativeImport(baseType, fromFile); + + final duplicate = 'get$pName()'; + + // ── 1. the contract ──────────────────────────────────────────────────────── + final contract = ''' + Future<$requestObject> get$pName(); + + Future set$pName($requestObject data);'''; + + _inject( + connectPath, + contract, + duplicate: duplicate, + importLine: modelImportFor(connectPath), + logger: logger, + ); + + // ── 2. the implementation ────────────────────────────────────────────────── + final read = secure + ? "await SecretStore.read($keyConst)" + : "(await SharedPreferences.getInstance()).getString($keyConst)"; + final write = secure + ? (String v) => "await SecretStore.write($keyConst, $v);" + : (String v) => + "await (await SharedPreferences.getInstance()).setString($keyConst, $v);"; + + String getBody; + String setBody; + + if (isString) { + getBody = ' return $read ?? \'\';'; + setBody = ' ${write('data')}'; + } else if (isList) { + getBody = ''' + final stored = $read; + if (stored == null || stored.isEmpty) return []; + return (jsonDecode(stored) as List) + .map((item) => $baseType.fromJson(item as Map)) + .toList();'''; + setBody = ''' + final encoded = jsonEncode(data.map((item) => item.toJson()).toList()); + ${write('encoded')}'''; + } else { + getBody = ''' + final stored = $read; + if (stored == null || stored.isEmpty) return null; + return $baseType.fromJson(jsonDecode(stored) as Map);'''; + setBody = ''' + ${write('jsonEncode(data.toJson())')}'''; + } + + // A single object can legitimately be absent, so its getter is nullable. + final returnType = (isString || isList) ? requestObject : '$requestObject?'; + + final implementation = ''' + @override + Future<$returnType> get$pName() async { +$getBody + } + + @override + Future set$pName($requestObject data) async { +$setBody + }'''; + + _injectImplementation( + memoryPath, + keyLine: " static const $keyConst = '$keyValue';", + methods: implementation, + duplicate: duplicate, + importLine: modelImportFor(memoryPath), + needsConvert: !isString, + needsSecretStore: secure, + needsPrefs: !secure, + logger: logger, + ); + + // Keep the contract's nullability in step with the implementation. + if (!isString && !isList) { + _replaceInFile( + connectPath, + 'Future<$requestObject> get$pName();', + 'Future<$requestObject?> get$pName();', + ); + } + + // ── 3. the delegation ────────────────────────────────────────────────────── + final delegation = ''' + @override + Future<$returnType> get$pName() => memory.get$pName(); + + @override + Future set$pName($requestObject data) => memory.set$pName(data);'''; + + _inject( + managerPath, + delegation, + duplicate: duplicate, + importLine: modelImportFor(managerPath), + logger: logger, + ); + + try { + await Process.run('dart', ['format', 'lib/Peach/'], runInShell: true); + } catch (_) { + // Formatting is a nicety; a missing dart on PATH shouldn't fail the run. + } + + logger.success( + '✨ $pName wired through ConnectInternalMemory, InternalMemory and ' + 'AppDataManager.'); +} + +/// A random-suffixed key, so a stored secret isn't sitting under a guessable +/// name in the OS keystore. +String _secureKeyValue(String field) { + final random = Random.secure(); + final bytes = List.generate(12, (_) => random.nextInt(256)); + final suffix = + base64Url.encode(bytes).replaceAll('=', '').substring(0, 16); + return 'peach.${field.snakeCase}.$suffix'; +} + +/// Finds [className]'s file and builds an import relative to [fromFile] β€” the +/// project uses relative imports inside lib/Peach, not package: ones. +String? _relativeImport(String className, String fromFile) { + final dir = Directory('lib'); + if (!dir.existsSync()) return null; + + for (final entity in dir.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final content = entity.readAsStringSync(); + // Built from raw parts: in a normal string `\s` would collapse to a plain + // `s`, quietly turning this into a pattern that never matches. + final declaration = + RegExp(r'(class|enum)\s+' + RegExp.escape(className) + r'\b'); + if (!declaration.hasMatch(content)) continue; + + final target = entity.path.replaceAll(r'\', '/'); + final from = fromFile.replaceAll(r'\', '/'); + return "import '${_relativePath(from, target)}';"; + } + return null; +} + +String _relativePath(String fromFile, String toFile) { + final fromParts = fromFile.split('/')..removeLast(); + final toParts = toFile.split('/'); + var shared = 0; + while (shared < fromParts.length && + shared < toParts.length - 1 && + fromParts[shared] == toParts[shared]) { + shared++; + } + final up = List.filled(fromParts.length - shared, '..'); + final down = toParts.sublist(shared); + return [...up, ...down].join('/'); +} + +/// Appends [content] just before the class's closing brace. +void _inject( + String path, + String content, { + required String duplicate, + required Logger logger, + String? importLine, +}) { + final file = File(path); + if (!file.existsSync()) { + logger.err('Missing $path β€” is this being run from the project root?'); + return; + } + + var source = file.readAsStringSync(); + if (source.contains(duplicate)) { + logger.info('$path already has $duplicate β€” skipped.'); + return; + } + + source = _addImport(source, importLine); + + final close = source.lastIndexOf('}'); + if (close == -1) return; + file.writeAsStringSync( + '${source.substring(0, close).trimRight()}\n\n$content\n${source.substring(close)}'); +} + +/// Like [_inject], but also puts the storage key at the top of the class and +/// pulls in whatever the generated body needs. +void _injectImplementation( + String path, { + required String keyLine, + required String methods, + required String duplicate, + required bool needsConvert, + required bool needsSecretStore, + required bool needsPrefs, + required Logger logger, + String? importLine, +}) { + final file = File(path); + if (!file.existsSync()) { + logger.err('Missing $path β€” is this being run from the project root?'); + return; + } + + var source = file.readAsStringSync(); + if (source.contains(duplicate)) { + logger.info('$path already has $duplicate β€” skipped.'); + return; + } + + source = _addImport(source, importLine); + if (needsConvert) source = _addImport(source, "import 'dart:convert';"); + if (needsPrefs) { + source = _addImport( + source, "import 'package:shared_preferences/shared_preferences.dart';"); + } + if (needsSecretStore) { + source = _addImport(source, "import 'SecretStore.dart';"); + } + + final classOpen = source.indexOf('{', source.indexOf('class InternalMemory')); + if (classOpen != -1) { + source = + '${source.substring(0, classOpen + 1)}\n$keyLine\n${source.substring(classOpen + 1)}'; + } + + final close = source.lastIndexOf('}'); + if (close == -1) return; + file.writeAsStringSync( + '${source.substring(0, close).trimRight()}\n\n$methods\n${source.substring(close)}'); +} + +String _addImport(String source, String? importLine) { + if (importLine == null || source.contains(importLine)) return source; + return '$importLine\n$source'; +} + +void _replaceInFile(String path, String from, String to) { + final file = File(path); + if (!file.existsSync()) return; + final source = file.readAsStringSync(); + if (!source.contains(from)) return; + file.writeAsStringSync(source.replaceFirst(from, to)); +} diff --git a/bricks/internal_memory/hooks/pubspec.yaml b/bricks/internal_memory/hooks/pubspec.yaml new file mode 100644 index 0000000..edbcba7 --- /dev/null +++ b/bricks/internal_memory/hooks/pubspec.yaml @@ -0,0 +1,7 @@ +name: internal_memory_hooks + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + mason: ^0.1.0-dev diff --git a/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/Connect{{screen}}.dart b/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/Connect{{screen}}.dart new file mode 100644 index 0000000..0bf174d --- /dev/null +++ b/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/Connect{{screen}}.dart @@ -0,0 +1,5 @@ +/// The callbacks [View{{screen}}] uses to hand results back to the screen, so +/// the ViewModel never touches widgets. +abstract class Connect{{screen}} { + // void onSomethingLoaded(SomeModel data); +} diff --git a/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/View{{screen}}.dart b/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/View{{screen}}.dart new file mode 100644 index 0000000..0403a99 --- /dev/null +++ b/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/View{{screen}}.dart @@ -0,0 +1,24 @@ +import '../parent/ParentViewModel.dart'; +import 'Connect{{screen}}.dart'; + +class View{{screen}} extends ParentViewModel { + Connect{{screen}} connection; + + View{{screen}}(super.context, this.connection); + + // Every server-bound call follows this shape: guard with canReach, show the + // loading overlay, hand the result back through `connection`, and route any + // failure to handleError. + // + // void loadSomething(ServerProfile server) async { + // if (!await canReach(server, () => loadSomething(server))) return; + // showLoading('Loading…'); + // try { + // final result = await getDataManager().someCall(server); + // closeLoading(); + // connection.onSomethingLoaded(result); + // } catch (error) { + // handleError(error, () => loadSomething(server), () {}, 'Retry'); + // } + // } +} diff --git a/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}.dart b/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}.dart new file mode 100644 index 0000000..3150016 --- /dev/null +++ b/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}.dart @@ -0,0 +1,10 @@ +import 'package:flutter/cupertino.dart'; + +import '{{screen}}State.dart'; + +class {{screen}} extends StatefulWidget { + const {{screen}}({super.key}); + + @override + State<{{screen}}> createState() => {{screen}}State(); +} diff --git a/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}State.dart b/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}State.dart new file mode 100644 index 0000000..a3b2b0a --- /dev/null +++ b/bricks/mvvc_template/__brick__/lib/{{project}}/see/{{screen.lowerCase()}}/{{screen}}State.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../designs/Responsive.dart'; +import 'Connect{{screen}}.dart'; +import 'View{{screen}}.dart'; +import '{{screen}}.dart'; + +class {{screen}}State extends State<{{screen}}> implements Connect{{screen}} { + View{{screen}}? _model; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => View{{screen}}(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + body: LayoutBuilder( + builder: (context, constraints) => Responsive( + mobile: _mobileView(constraints), + tablet: _tabletView(constraints), + desktop: _desktopView(constraints), + ), + ), + ), + ); + } + + void _onReady(View{{screen}} model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + /// The single entry point for whatever this screen loads on open. Kick off + /// every initial load from here β€” never inline in `onViewModelReady`. + Future _initiate() async { + assert(_model != null, 'View{{screen}} should be ready before _initiate'); + // _model?.loadSomething(); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + // ── Connect{{screen}} ────────────────────────────────────────────────────── + + // ── layouts ──────────────────────────────────────────────────────────────── + + Widget _mobileView(BoxConstraints constraints) { + return const SizedBox(); + } + + Widget _tabletView(BoxConstraints constraints) { + return const SizedBox(); + } + + Widget _desktopView(BoxConstraints constraints) { + return const SizedBox(); + } +} diff --git a/bricks/mvvc_template/brick.yaml b/bricks/mvvc_template/brick.yaml new file mode 100644 index 0000000..76598b3 --- /dev/null +++ b/bricks/mvvc_template/brick.yaml @@ -0,0 +1,21 @@ +name: mvvc_template +description: Generates a Peach screen as the mandatory four files β€” the + StatefulWidget shell, its State (UI + Connect implementation), the ViewModel + (logic, talks to the DataManager) and the Connect interface between them. + +version: 0.1.0+1 + +environment: + mason: ^0.1.1 + +vars: + project: + type: string + description: The top-level folder under lib/ (also the package name) + default: Peach + prompt: What is your project name? + screen: + type: string + description: The screen name in PascalCase, e.g. ServerDetail + default: NewScreen + prompt: What is your screen name? diff --git a/lib/Peach/about/external/data/Docker.dart b/lib/Peach/about/external/data/Docker.dart new file mode 100644 index 0000000..bc99711 --- /dev/null +++ b/lib/Peach/about/external/data/Docker.dart @@ -0,0 +1,56 @@ +class DockerStatus { + final bool installed; + final bool running; + final String version; + DockerStatus(this.installed, this.running, this.version); +} + +class DockerContainer { + final String id; + final String name; + final String image; + final String state; // running | exited | created | paused | restarting | dead + final String status; // e.g. "Up 2 hours" / "Exited (0) 3 min ago" + final String ports; + + DockerContainer({ + required this.id, + required this.name, + required this.image, + required this.state, + required this.status, + required this.ports, + }); + + bool get up => state.toLowerCase() == 'running'; +} + +/// Everything needed to `docker run` a new container. +class DockerRunSpec { + String image; + String name; + String ports; // "host:container" per line + String env; // "KEY=VALUE" per line + String volumes; // "host:container" per line + String restart; // no | on-failure | always | unless-stopped + bool detached; + String command; + + DockerRunSpec({ + this.image = '', + this.name = '', + this.ports = '', + this.env = '', + this.volumes = '', + this.restart = 'unless-stopped', + this.detached = true, + this.command = '', + }); + + List _lines(String s) => + s.split('\n').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(); + + List get portList => _lines(ports); + List get envList => _lines(env); + List get volumeList => _lines(volumes); +} diff --git a/lib/Peach/about/external/data/EnvCheck.dart b/lib/Peach/about/external/data/EnvCheck.dart new file mode 100644 index 0000000..2aa64c7 --- /dev/null +++ b/lib/Peach/about/external/data/EnvCheck.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +/// A tool/runtime PeachDeployments can detect on a server and install if +/// missing. [detect] prints a version line to stdout when present, nothing +/// otherwise (guarded by `command -v`). [pkg] maps a package manager to the +/// package(s) to install. [unit] is a systemd unit to `enable --now` after. +class EnvTool { + final String key; + final String label; + final IconData icon; + final String detect; + final Map pkg; + final String? unit; + + const EnvTool( + this.key, + this.label, + this.icon, + this.detect, + this.pkg, { + this.unit, + }); + + String packagesFor(String pm) => pkg[pm] ?? key; +} + +const List kEnvTools = [ + EnvTool( + 'java', + 'Java (JDK)', + Icons.coffee, + 'command -v java >/dev/null 2>&1 && java -version 2>&1 | head -n1', + { + 'apt': 'openjdk-17-jdk', + 'dnf': 'java-17-openjdk-devel', + 'yum': 'java-17-openjdk-devel', + 'apk': 'openjdk17', + 'zypper': 'java-17-openjdk-devel', + 'pacman': 'jdk-openjdk', + }, + ), + EnvTool( + 'mariadb', + 'MariaDB / MySQL', + Icons.storage, + '(command -v mariadb >/dev/null 2>&1 && mariadb --version 2>&1 | head -n1) || ' + '(command -v mysql >/dev/null 2>&1 && mysql --version 2>&1 | head -n1)', + { + 'apt': 'mariadb-server', + 'dnf': 'mariadb-server', + 'yum': 'mariadb-server', + 'apk': 'mariadb mariadb-client', + 'zypper': 'mariadb', + 'pacman': 'mariadb', + }, + unit: 'mariadb', + ), + EnvTool( + 'postgres', + 'PostgreSQL', + Icons.dataset, + 'command -v psql >/dev/null 2>&1 && psql --version 2>&1 | head -n1', + { + 'apt': 'postgresql', + 'dnf': 'postgresql-server', + 'yum': 'postgresql-server', + 'apk': 'postgresql', + 'zypper': 'postgresql-server', + 'pacman': 'postgresql', + }, + unit: 'postgresql', + ), + EnvTool( + 'nginx', + 'nginx', + Icons.dns, + 'command -v nginx >/dev/null 2>&1 && nginx -v 2>&1 | head -n1', + { + 'apt': 'nginx', + 'dnf': 'nginx', + 'yum': 'nginx', + 'apk': 'nginx', + 'zypper': 'nginx', + 'pacman': 'nginx', + }, + unit: 'nginx', + ), + EnvTool( + 'docker', + 'Docker', + Icons.inventory_2, + 'command -v docker >/dev/null 2>&1 && docker --version 2>&1 | head -n1', + { + 'apt': 'docker.io', + 'dnf': 'docker', + 'yum': 'docker', + 'apk': 'docker', + 'zypper': 'docker', + 'pacman': 'docker', + }, + unit: 'docker', + ), + EnvTool( + 'node', + 'Node.js', + Icons.javascript, + 'command -v node >/dev/null 2>&1 && node --version 2>&1 | head -n1', + { + 'apt': 'nodejs npm', + 'dnf': 'nodejs', + 'yum': 'nodejs', + 'apk': 'nodejs npm', + 'zypper': 'nodejs', + 'pacman': 'nodejs npm', + }, + ), + EnvTool( + 'git', + 'Git', + Icons.merge_type, + 'command -v git >/dev/null 2>&1 && git --version 2>&1 | head -n1', + { + 'apt': 'git', + 'dnf': 'git', + 'yum': 'git', + 'apk': 'git', + 'zypper': 'git', + 'pacman': 'git', + }, + ), +]; + +/// Builds the install command for a detected package manager. +String installCommandFor(String pm, String packages) { + switch (pm) { + case 'apt': + return 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y $packages'; + case 'dnf': + return 'dnf install -y $packages'; + case 'yum': + return 'yum install -y $packages'; + case 'apk': + return 'apk add --no-cache $packages'; + case 'zypper': + return 'zypper --non-interactive install $packages'; + case 'pacman': + return 'pacman -Sy --noconfirm $packages'; + default: + return 'echo "Unsupported package manager"; false'; + } +} diff --git a/lib/Peach/about/external/data/Health.dart b/lib/Peach/about/external/data/Health.dart new file mode 100644 index 0000000..d79afbd --- /dev/null +++ b/lib/Peach/about/external/data/Health.dart @@ -0,0 +1,10 @@ +enum HealthStatus { healthy, unhealthy, unknown, disabled, checking } + +class HealthResult { + final HealthStatus status; + final String detail; + final DateTime time; + HealthResult(this.status, this.detail) : time = DateTime.now(); + + static HealthResult checking() => HealthResult(HealthStatus.checking, '…'); +} diff --git a/lib/Peach/about/external/data/Kafka.dart b/lib/Peach/about/external/data/Kafka.dart new file mode 100644 index 0000000..dac06ed --- /dev/null +++ b/lib/Peach/about/external/data/Kafka.dart @@ -0,0 +1,170 @@ +// Models for the Kafka monitor. All data is scraped from the standard Kafka +// CLI tools (`kafka-topics`, `kafka-consumer-groups`, `kafka-get-offsets`, +// `kafka-broker-api-versions`) run over SSH. + +/// Result of locating the Kafka tooling on a server. +class KafkaStatus { + final bool detected; + final String binDir; // e.g. /opt/kafka/bin + final String suffix; // ".sh" for Apache tarballs, "" for Confluent packages + final bool java; // a JVM is on PATH + final String version; // best-effort version string + final String detail; // human note (where it was found / why not) + + KafkaStatus({ + required this.detected, + this.binDir = '', + this.suffix = '.sh', + this.java = false, + this.version = '', + this.detail = '', + }); +} + +class KafkaPartition { + final int id; + final int leader; + final List replicas; + final List isr; + int earliest; + int latest; // high-watermark (log-end offset) + + KafkaPartition({ + required this.id, + required this.leader, + required this.replicas, + required this.isr, + this.earliest = 0, + this.latest = 0, + }); + + /// Messages currently retained on this partition. + int get messages => (latest - earliest).clamp(0, 1 << 62); + + /// Replica set smaller than in-sync means the partition is healthy but the + /// ISR shrinking below the replica count means it is under-replicated. + bool get underReplicated => isr.length < replicas.length; + + bool get offline => leader < 0; +} + +class KafkaTopic { + final String name; + final int replicationFactor; + final String configs; + final List partitions; + + KafkaTopic({ + required this.name, + required this.replicationFactor, + required this.partitions, + this.configs = '', + }); + + bool get internal => name.startsWith('_') || name.startsWith('__'); + int get partitionCount => partitions.length; + + /// Total high-watermark across partitions β€” the number of records ever + /// produced (ignoring compaction), a good proxy for "messages sent". + int get highWatermark => partitions.fold(0, (s, p) => s + p.latest); + + /// Messages currently retained on the topic (latest βˆ’ earliest). + int get retained => partitions.fold(0, (s, p) => s + p.messages); + + int get underReplicatedCount => + partitions.where((p) => p.underReplicated).length; + int get offlineCount => partitions.where((p) => p.offline).length; + bool get healthy => underReplicatedCount == 0 && offlineCount == 0; +} + +/// One row of `kafka-consumer-groups --describe` (per topic/partition). +class KafkaGroupAssignment { + final String topic; + final int partition; + final int currentOffset; // committed / processed + final int logEndOffset; // produced + final int lag; + final String consumerId; + final String host; + final String clientId; + + KafkaGroupAssignment({ + required this.topic, + required this.partition, + required this.currentOffset, + required this.logEndOffset, + required this.lag, + this.consumerId = '', + this.host = '', + this.clientId = '', + }); +} + +class KafkaConsumerGroup { + final String groupId; + String state; // Stable | Empty | Dead | PreparingRebalance | ... + int members; + final String coordinator; + final String assignmentStrategy; + final List assignments; + + KafkaConsumerGroup({ + required this.groupId, + this.state = '', + this.members = 0, + this.coordinator = '', + this.assignmentStrategy = '', + List? assignments, + }) : assignments = assignments ?? []; + + int get totalLag => + assignments.fold(0, (s, a) => s + (a.lag < 0 ? 0 : a.lag)); + int get processed => assignments.fold(0, (s, a) => s + a.currentOffset); + Set get topics => assignments.map((a) => a.topic).toSet(); + int get liveMembers => assignments + .map((a) => a.consumerId) + .where((c) => c.isNotEmpty && c != '-') + .toSet() + .length; + + bool get active => state.toLowerCase() == 'stable'; + bool get idle => state.toLowerCase() == 'empty'; +} + +class KafkaBroker { + final int id; + final String host; + final int port; + final String rack; + bool isController; + + KafkaBroker({ + required this.id, + required this.host, + required this.port, + this.rack = '', + this.isController = false, + }); + + String get endpoint => '$host:$port'; +} + +/// Cluster-level snapshot: brokers + health counts. +class KafkaCluster { + final List brokers; + final String clusterId; + final int controllerId; + final List underReplicated; // "topic-partition" strings + final List unavailable; + + KafkaCluster({ + required this.brokers, + this.clusterId = '', + this.controllerId = -1, + List? underReplicated, + List? unavailable, + }) : underReplicated = underReplicated ?? [], + unavailable = unavailable ?? []; + + bool get healthy => underReplicated.isEmpty && unavailable.isEmpty; +} diff --git a/lib/Peach/about/external/data/PeachError.dart b/lib/Peach/about/external/data/PeachError.dart new file mode 100644 index 0000000..2d46978 --- /dev/null +++ b/lib/Peach/about/external/data/PeachError.dart @@ -0,0 +1,53 @@ +import '../../internal/application/Severity.dart'; + +/// The single error shape every failure is normalised into before it reaches +/// the user. SSH, build and archive failures are all mapped onto this by +/// `ParentViewModel.handleError`, so the error overlay only ever renders one +/// kind of object. +class PeachError { + final double code; + final String message; + final String helper; + final String title; + final String severity; + + PeachError({ + required this.code, + required this.message, + required this.helper, + required this.title, + required this.severity, + }); + + factory PeachError.fromJsonMap(Map json) => PeachError( + code: (json['code'] as num?)?.toDouble() ?? 0, + message: json['message'] as String? ?? '', + helper: json['helper'] as String? ?? '', + title: json['title'] as String? ?? 'Peach Error', + severity: json['severity'] as String? ?? Severity.message.name, + ); + + Map toJson() => { + 'code': code, + 'message': message, + 'helper': helper, + 'title': title, + 'severity': severity, + }; +} + +/// Best-effort decode of an error payload into a [PeachError]. Anything that +/// isn't a recognisable map is surfaced as a generic error rather than thrown, +/// so the overlay always has something to show. +PeachError getPeachError(dynamic data) { + if (data is Map) return PeachError.fromJsonMap(data); + return PeachError( + code: 9000.01, + message: data?.toString() ?? 'An unknown error occurred.', + helper: + 'Retry the operation, and if the problem persists check the ' + 'server and your connection details.', + title: 'Peach Error', + severity: Severity.message.name, + ); +} diff --git a/lib/Peach/about/external/data/Redis.dart b/lib/Peach/about/external/data/Redis.dart new file mode 100644 index 0000000..51825bc --- /dev/null +++ b/lib/Peach/about/external/data/Redis.dart @@ -0,0 +1,24 @@ +/// A single Redis key's type, TTL and (formatted) value. +class RedisEntry { + final String key; + final String type; // string | list | set | hash | zset | stream | none + final String ttl; // seconds, -1 (no expiry), -2 (missing) + final String value; + RedisEntry({ + required this.key, + required this.type, + required this.ttl, + required this.value, + }); + + String get ttlLabel { + final n = int.tryParse(ttl.trim()); + if (n == null) return ttl; + if (n == -1) return 'no expiry'; + if (n == -2) return 'missing'; + if (n < 60) return '${n}s'; + if (n < 3600) return '${(n / 60).floor()}m ${n % 60}s'; + if (n < 86400) return '${(n / 3600).floor()}h'; + return '${(n / 86400).floor()}d'; + } +} diff --git a/lib/Peach/about/external/initial/SystemdSpec.dart b/lib/Peach/about/external/initial/SystemdSpec.dart new file mode 100644 index 0000000..041834b --- /dev/null +++ b/lib/Peach/about/external/initial/SystemdSpec.dart @@ -0,0 +1,125 @@ +/// systemd service [Type]. +enum SystemdType { simple, exec, forking, oneshot, notify } + +/// systemd Restart= policy. +enum RestartPolicy { onFailure, always, no } + +extension RestartPolicyX on RestartPolicy { + String get value => switch (this) { + RestartPolicy.onFailure => 'on-failure', + RestartPolicy.always => 'always', + RestartPolicy.no => 'no', + }; + String get label => switch (this) { + RestartPolicy.onFailure => 'On failure', + RestartPolicy.always => 'Always', + RestartPolicy.no => 'Never', + }; +} + +/// Everything needed to generate and register a `.service` unit file. +class SystemdSpec { + String unitName; // e.g. "orders" -> orders.service + String description; + SystemdType type; + String user; + String group; + String workingDirectory; + String execStart; + RestartPolicy restart; + int restartSec; + + /// Raw "KEY=VALUE" lines. + String environmentLines; + + /// Extra `After=`/`Wants=` targets, e.g. mariadb.service. + List afterUnits; + + /// Add `SuccessExitStatus=143` (SIGTERM) β€” recommended for JVM apps. + bool javaSuccessExit; + + bool enableOnBoot; + bool startNow; + + SystemdSpec({ + this.unitName = '', + this.description = '', + this.type = SystemdType.simple, + this.user = 'root', + this.group = '', + this.workingDirectory = '', + this.execStart = '', + this.restart = RestartPolicy.onFailure, + this.restartSec = 5, + this.environmentLines = '', + List? afterUnits, + this.javaSuccessExit = false, + this.enableOnBoot = true, + this.startNow = true, + }) : afterUnits = afterUnits ?? ['network.target']; + + /// Sanitized unit name (no spaces / .service suffix). + String get safeName { + var n = unitName.trim(); + if (n.toLowerCase().endsWith('.service')) { + n = n.substring(0, n.length - '.service'.length); + } + return n.replaceAll(RegExp(r'[^A-Za-z0-9_.@-]'), '-'); + } + + List get envList => environmentLines + .split('\n') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty && e.contains('=')) + .toList(); + + /// Renders the unit file contents. + String render() { + final b = StringBuffer(); + b.writeln('# Generated by PeachDeployments'); + b.writeln('[Unit]'); + b.writeln( + 'Description=${description.trim().isEmpty ? safeName : description.trim()}', + ); + final afters = afterUnits.where((a) => a.trim().isNotEmpty).toList(); + if (afters.isNotEmpty) { + b.writeln('After=${afters.join(' ')}'); + // Wants only the non-core targets (don't hard-require network.target). + final wants = afters.where((a) => a != 'network.target').toList(); + if (wants.isNotEmpty) b.writeln('Wants=${wants.join(' ')}'); + } + b.writeln(); + b.writeln('[Service]'); + b.writeln('Type=${type.name}'); + if (user.trim().isNotEmpty) b.writeln('User=${user.trim()}'); + if (group.trim().isNotEmpty) b.writeln('Group=${group.trim()}'); + if (workingDirectory.trim().isNotEmpty) { + b.writeln('WorkingDirectory=${workingDirectory.trim()}'); + } + for (final e in envList) { + b.writeln('Environment="$e"'); + } + b.writeln('ExecStart=${execStart.trim()}'); + b.writeln('Restart=${restart.value}'); + if (restart != RestartPolicy.no) b.writeln('RestartSec=$restartSec'); + if (javaSuccessExit) b.writeln('SuccessExitStatus=143'); + b.writeln(); + b.writeln('[Install]'); + b.writeln('WantedBy=multi-user.target'); + return b.toString(); + } + + /// Human-readable list of the commands the installer will run. + List plannedCommands({required bool sudo}) { + final p = sudo ? 'sudo ' : ''; + final target = '/etc/systemd/system/$safeName.service'; + return [ + 'write $target', + '${p}chmod 644 $target', + '${p}systemctl daemon-reload', + if (enableOnBoot) '${p}systemctl enable $safeName', + if (startNow) '${p}systemctl restart $safeName', + '${p}systemctl status $safeName', + ]; + } +} diff --git a/lib/Peach/about/internal/application/DeploymentRecord.dart b/lib/Peach/about/internal/application/DeploymentRecord.dart new file mode 100644 index 0000000..fed5058 --- /dev/null +++ b/lib/Peach/about/internal/application/DeploymentRecord.dart @@ -0,0 +1,65 @@ +enum DeployStatus { success, failure, reverted } + +extension DeployStatusX on DeployStatus { + String get label => switch (this) { + DeployStatus.success => 'Success', + DeployStatus.failure => 'Failure', + DeployStatus.reverted => 'Reverted', + }; +} + +/// One historical deployment attempt, tracked for auditing. +class DeploymentRecord { + final String id; + final String serverId; + final String serverName; + final String serviceId; + final String serviceLabel; + final String jarName; + final String remotePath; + final DateTime time; + final DeployStatus status; + final String message; + + DeploymentRecord({ + required this.id, + required this.serverId, + required this.serverName, + required this.serviceId, + required this.serviceLabel, + required this.jarName, + required this.remotePath, + required this.time, + required this.status, + required this.message, + }); + + Map toJson() => { + 'id': id, + 'serverId': serverId, + 'serverName': serverName, + 'serviceId': serviceId, + 'serviceLabel': serviceLabel, + 'jarName': jarName, + 'remotePath': remotePath, + 'time': time.toIso8601String(), + 'status': status.name, + 'message': message, + }; + + factory DeploymentRecord.fromJson(Map j) => DeploymentRecord( + id: j['id'] as String, + serverId: (j['serverId'] ?? '') as String, + serverName: (j['serverName'] ?? '') as String, + serviceId: (j['serviceId'] ?? '') as String, + serviceLabel: (j['serviceLabel'] ?? '') as String, + jarName: (j['jarName'] ?? '') as String, + remotePath: (j['remotePath'] ?? '') as String, + time: DateTime.tryParse((j['time'] ?? '') as String) ?? DateTime(2000), + status: DeployStatus.values.firstWhere( + (s) => s.name == j['status'], + orElse: () => DeployStatus.failure, + ), + message: (j['message'] ?? '') as String, + ); +} diff --git a/lib/Peach/about/internal/application/NavigatorType.dart b/lib/Peach/about/internal/application/NavigatorType.dart new file mode 100644 index 0000000..93c4d70 --- /dev/null +++ b/lib/Peach/about/internal/application/NavigatorType.dart @@ -0,0 +1,3 @@ +/// How a destination replaces (or stacks on) the current screen. Every +/// transition in the app goes through `PeachNavigation` with one of these. +enum NavigatorType { justOpen, openFully, replaceCurrent, makeNewMain } diff --git a/lib/Peach/about/internal/application/NotificationType.dart b/lib/Peach/about/internal/application/NotificationType.dart new file mode 100644 index 0000000..771e129 --- /dev/null +++ b/lib/Peach/about/internal/application/NotificationType.dart @@ -0,0 +1,2 @@ +/// Severity of an in-app notification sheet (`showApplicationNotification`). +enum NotificationType { success, info, warning, error } diff --git a/lib/Peach/about/internal/application/Pair.dart b/lib/Peach/about/internal/application/Pair.dart new file mode 100644 index 0000000..c325cd3 --- /dev/null +++ b/lib/Peach/about/internal/application/Pair.dart @@ -0,0 +1,8 @@ +/// A generic two-slot carrier, used where a call needs to hand back two values +/// without earning a named model (e.g. a resolved command plus its headers). +class Pair { + final K key; + final V value; + + const Pair(this.key, this.value); +} diff --git a/lib/Peach/about/internal/application/ServerProfile.dart b/lib/Peach/about/internal/application/ServerProfile.dart new file mode 100644 index 0000000..57d50f3 --- /dev/null +++ b/lib/Peach/about/internal/application/ServerProfile.dart @@ -0,0 +1,85 @@ +import 'package:uuid/uuid.dart'; +import 'ServiceConfig.dart'; + +/// A unique server the user connects to. Holds the connection details plus a +/// list of deployable [ServiceConfig]s that live on it. +/// +/// The [password] is NOT serialized into the plain config JSON β€” it is held in +/// memory and persisted separately via encrypted secure storage. +class ServerProfile { + final String id; + String name; // friendly label, e.g. "prod-1" + String host; + int port; + String username; + String password; // runtime only; persisted via SecretStore + bool useSudo; + List services; + + /// Saved deployment order: service ids in the order the user arranged them + /// for batch build & deploy. Ids not present are appended in natural order. + List deployOrder; + + ServerProfile({ + String? id, + this.name = 'New server', + this.host = '', + this.port = 22, + this.username = '', + this.password = '', + this.useSudo = true, + List? services, + List? deployOrder, + }) : id = id ?? const Uuid().v4(), + services = services ?? [], + deployOrder = deployOrder ?? []; + + /// Services arranged by the saved [deployOrder], with any not-listed ones + /// appended in their natural order. + List get orderedServices { + final byId = {for (final s in services) s.id: s}; + final result = []; + for (final id in deployOrder) { + final s = byId.remove(id); + if (s != null) result.add(s); + } + // Preserve original order for the remaining (new/unordered) services. + for (final s in services) { + if (byId.containsKey(s.id)) result.add(s); + } + return result; + } + + ServerProfile copy() { + final c = ServerProfile.fromJson(toJson()); + c.password = password; + return c; + } + + /// JSON WITHOUT the password (that goes to secure storage). + Map toJson() => { + 'id': id, + 'name': name, + 'host': host, + 'port': port, + 'username': username, + 'useSudo': useSudo, + 'services': services.map((s) => s.toJson()).toList(), + 'deployOrder': deployOrder, + }; + + factory ServerProfile.fromJson(Map j) => ServerProfile( + id: j['id'] as String?, + name: (j['name'] ?? 'server') as String, + host: (j['host'] ?? '') as String, + port: (j['port'] ?? 22) as int, + username: (j['username'] ?? '') as String, + useSudo: (j['useSudo'] ?? true) as bool, + services: ((j['services'] ?? []) as List) + .map((e) => ServiceConfig.fromJson(e as Map)) + .toList(), + deployOrder: ((j['deployOrder'] ?? []) as List) + .map((e) => e.toString()) + .toList(), + ); +} diff --git a/lib/Peach/about/internal/application/ServiceConfig.dart b/lib/Peach/about/internal/application/ServiceConfig.dart new file mode 100644 index 0000000..70f388c --- /dev/null +++ b/lib/Peach/about/internal/application/ServiceConfig.dart @@ -0,0 +1,183 @@ +import 'package:uuid/uuid.dart'; + +/// What kind of thing this service deploys. +enum ServiceKind { jarService, webApp, generic } + +/// Front-end framework for a [ServiceKind.webApp]. +enum WebFramework { angular, react, flutterWeb, staticFiles } + +/// How a service's health is probed. +enum HealthCheckType { auto, http, tcp, command, none } + +extension ServiceKindX on ServiceKind { + String get label => switch (this) { + ServiceKind.webApp => 'Web app (nginx)', + ServiceKind.generic => 'Service (any binary)', + ServiceKind.jarService => 'Jar service', + }; +} + +extension HealthCheckTypeX on HealthCheckType { + String get label => switch (this) { + HealthCheckType.auto => 'systemd (auto)', + HealthCheckType.http => 'HTTP request', + HealthCheckType.tcp => 'TCP port', + HealthCheckType.command => 'Command exit 0', + HealthCheckType.none => 'Disabled', + }; +} + +extension WebFrameworkX on WebFramework { + String get label => switch (this) { + WebFramework.angular => 'Angular', + WebFramework.react => 'React', + WebFramework.flutterWeb => 'Flutter web', + WebFramework.staticFiles => 'Static files', + }; + + /// Default build command (run through a shell). Blank = no build. + String get defaultBuild => switch (this) { + WebFramework.angular => 'npm install && npm run build', + WebFramework.react => 'npm install && npm run build', + WebFramework.flutterWeb => 'flutter build web --release', + WebFramework.staticFiles => '', + }; +} + +/// A single deployable service that lives on a [ServerProfile]. +/// One server can host many of these β€” jar services and/or web apps. +class ServiceConfig { + final String id; + String name; // friendly label, e.g. "orders-api" + + ServiceKind kind; + WebFramework framework; + + /// Absolute target directory on the server. + /// - jar service: where the jar lives, e.g. /opt/orders + /// - web app: the nginx web root, e.g. /var/www/orders + String remoteDir; + + /// The filename the uploaded jar should be saved as (jar services only). + String remoteJarName; + + /// The systemd unit to (re)start for a jar service, e.g. "orders". + String serviceName; + + /// The nginx unit to reload for a web app (web only). Default "nginx". + String nginxService; + + /// Optional custom log command (WITHOUT sudo β€” the deployer adds it). + String logCommand; + + /// Absolute path to the local source project. When set, PeachDeployments can build + /// from source before deploying. + String localProjectPath; + + /// Optional custom build command. Blank auto-detects. + String buildCommand; + + /// jar services: glob/path for the built jar (blank auto-detects). + String jarPattern; + + /// web apps: build output directory relative to the project (blank + /// auto-detects, e.g. dist/, build/, build/web). + String outputSubdir; + + /// How to probe this service's health. + HealthCheckType healthType; + + /// Health target: a URL (http), port number (tcp), or shell command. + String healthTarget; + + ServiceConfig({ + String? id, + this.name = 'New service', + this.kind = ServiceKind.jarService, + this.framework = WebFramework.react, + this.remoteDir = '/opt/app', + this.remoteJarName = '', + this.serviceName = '', + this.nginxService = 'nginx', + this.logCommand = '', + this.localProjectPath = '', + this.buildCommand = '', + this.jarPattern = '', + this.outputSubdir = '', + this.healthType = HealthCheckType.auto, + this.healthTarget = '', + }) : id = id ?? const Uuid().v4(); + + bool get isWeb => kind == ServiceKind.webApp; + bool get isGeneric => kind == ServiceKind.generic; + bool get canBuild => localProjectPath.trim().isNotEmpty; + + String get effectiveLogCommand { + if (logCommand.trim().isNotEmpty) return logCommand.trim(); + if (isWeb) { + final unit = nginxService.trim().isEmpty ? 'nginx' : nginxService.trim(); + return 'journalctl -u $unit -n 200 --no-pager'; + } + return 'journalctl -u ${serviceName.isEmpty ? 'app' : serviceName} ' + '-n 200 --no-pager'; + } + + /// Normalized remote directory (no trailing slash). + String get remoteRoot => remoteDir.endsWith('/') + ? remoteDir.substring(0, remoteDir.length - 1) + : remoteDir; + + String remotePath(String jarLabelFallback) { + final name = remoteJarName.trim().isNotEmpty + ? remoteJarName.trim() + : jarLabelFallback; + return '$remoteRoot/$name'; + } + + ServiceConfig copy() => ServiceConfig.fromJson(toJson()); + + Map toJson() => { + 'id': id, + 'name': name, + 'kind': kind.name, + 'framework': framework.name, + 'remoteDir': remoteDir, + 'remoteJarName': remoteJarName, + 'serviceName': serviceName, + 'nginxService': nginxService, + 'logCommand': logCommand, + 'localProjectPath': localProjectPath, + 'buildCommand': buildCommand, + 'jarPattern': jarPattern, + 'outputSubdir': outputSubdir, + 'healthType': healthType.name, + 'healthTarget': healthTarget, + }; + + factory ServiceConfig.fromJson(Map j) => ServiceConfig( + id: j['id'] as String?, + name: (j['name'] ?? 'service') as String, + kind: ServiceKind.values.firstWhere( + (k) => k.name == j['kind'], + orElse: () => ServiceKind.jarService, + ), + framework: WebFramework.values.firstWhere( + (f) => f.name == j['framework'], + orElse: () => WebFramework.react, + ), + remoteDir: (j['remoteDir'] ?? '/opt/app') as String, + remoteJarName: (j['remoteJarName'] ?? '') as String, + serviceName: (j['serviceName'] ?? '') as String, + nginxService: (j['nginxService'] ?? 'nginx') as String, + logCommand: (j['logCommand'] ?? '') as String, + localProjectPath: (j['localProjectPath'] ?? '') as String, + buildCommand: (j['buildCommand'] ?? '') as String, + jarPattern: (j['jarPattern'] ?? '') as String, + outputSubdir: (j['outputSubdir'] ?? '') as String, + healthType: HealthCheckType.values.firstWhere( + (h) => h.name == j['healthType'], + orElse: () => HealthCheckType.auto, + ), + healthTarget: (j['healthTarget'] ?? '') as String, + ); +} diff --git a/lib/Peach/about/internal/application/Severity.dart b/lib/Peach/about/internal/application/Severity.dart new file mode 100644 index 0000000..fe27049 --- /dev/null +++ b/lib/Peach/about/internal/application/Severity.dart @@ -0,0 +1,2 @@ +/// How loudly a [PeachError] should be presented. +enum Severity { message, warning, critical } diff --git a/lib/Peach/about/internal/application/StepTracker.dart b/lib/Peach/about/internal/application/StepTracker.dart new file mode 100644 index 0000000..64f58bb --- /dev/null +++ b/lib/Peach/about/internal/application/StepTracker.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +enum StepState { pending, active, done, failed, skipped } + +class DeployStep { + final String key; + final String label; + StepState state; + double? progress; // 0..1 within this step, when meaningful + + DeployStep( + this.key, + this.label, { + this.state = StepState.pending, + this.progress, + }); +} + +/// Tracks an ordered list of steps for a build or deploy operation and streams +/// updates so the UI can render a live stepper with per-step progress. +class StepTracker { + final _controller = StreamController>.broadcast(); + Stream> get stream => _controller.stream; + final List steps = []; + + void reset(List<(String, String)> defs) { + steps + ..clear() + ..addAll(defs.map((d) => DeployStep(d.$1, d.$2))); + _emit(); + } + + DeployStep? _find(String key) { + for (final s in steps) { + if (s.key == key) return s; + } + return null; + } + + void active(String key) { + final s = _find(key); + if (s == null) return; + // mark earlier active/pending steps done as we move forward + for (final step in steps) { + if (step == s) break; + if (step.state == StepState.active) step.state = StepState.done; + } + s.state = StepState.active; + s.progress = null; + _emit(); + } + + void progress(String key, double? p) { + final s = _find(key); + if (s == null) return; + s.progress = p; + _emit(); + } + + void done(String key) => _set(key, StepState.done); + void fail(String key) => _set(key, StepState.failed); + void skip(String key) => _set(key, StepState.skipped); + + void _set(String key, StepState state) { + final s = _find(key); + if (s == null) return; + s.state = state; + s.progress = null; + _emit(); + } + + /// Marks the currently-active step done (used before moving on). + void completeActive() { + for (final s in steps) { + if (s.state == StepState.active) s.state = StepState.done; + } + _emit(); + } + + /// Any still-pending/active steps become failed (on an error). + void failRemaining() { + for (final s in steps) { + if (s.state == StepState.active) { + s.state = StepState.failed; + } else if (s.state == StepState.pending) { + s.state = StepState.skipped; + } + } + _emit(); + } + + void clear() { + steps.clear(); + _emit(); + } + + void _emit() { + if (!_controller.isClosed) { + _controller.add(List.unmodifiable(steps)); + } + } + + void dispose() => _controller.close(); +} diff --git a/lib/Peach/about/internal/application/TextType.dart b/lib/Peach/about/internal/application/TextType.dart new file mode 100644 index 0000000..16ba439 --- /dev/null +++ b/lib/Peach/about/internal/application/TextType.dart @@ -0,0 +1,7 @@ +/// The three font roles every piece of display copy is drawn with. Resolved to +/// an actual family by `getTextType` in designs/text/Text.dart. +/// +/// Capitalised deliberately: these read as type names at every call site +/// (`text('Peach', 20, TextType.Bold)`), matching the reference architecture. +// ignore_for_file: constant_identifier_names +enum TextType { Bold, Light, Regular } diff --git a/lib/Peach/about/internal/file/BuildService.dart b/lib/Peach/about/internal/file/BuildService.dart new file mode 100644 index 0000000..25cd6d3 --- /dev/null +++ b/lib/Peach/about/internal/file/BuildService.dart @@ -0,0 +1,372 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../application/ServiceConfig.dart'; +import '../../../comms/Comms.dart' show LogLine, LogLevel; +import '../application/StepTracker.dart'; + +class BuildResult { + final bool ok; + final String message; + final File? jar; // jar services + final Directory? dir; // web apps (static output directory) + BuildResult(this.ok, this.message, {this.jar, this.dir}); +} + +/// Builds a jar from a local Maven/Gradle project (desktop only) and locates +/// the produced artifact so it can be deployed. +class BuildService { + final _logController = StreamController.broadcast(); + Stream get logs => _logController.stream; + final steps = StepTracker(); + Stream> get stepStream => steps.stream; + Process? _proc; + + void _log(LogLevel level, String text) { + for (final line in const LineSplitter().convert(text)) { + if (line.trim().isEmpty && level == LogLevel.remote) continue; + _logController.add(LogLine(level, line)); + } + } + + /// True on desktop, where local builds can run. + static bool get supported => + Platform.isWindows || Platform.isMacOS || Platform.isLinux; + + Future build(ServiceConfig svc) async { + steps.reset([ + ('detect', 'Detect project'), + ('compile', 'Compile'), + ('locate', 'Locate jar'), + ]); + if (!supported) { + steps.failRemaining(); + return BuildResult(false, 'Local builds are only available on desktop.'); + } + final dir = svc.localProjectPath.trim(); + if (dir.isEmpty) { + steps.failRemaining(); + return BuildResult(false, 'No project path set.'); + } + if (!Directory(dir).existsSync()) { + steps.failRemaining(); + return BuildResult(false, 'Project path does not exist: $dir'); + } + + steps.active('detect'); + final (exec, args, tool) = _resolveCommand(svc, dir); + if (exec == null) { + steps.failRemaining(); + return BuildResult( + false, + 'Could not detect a Maven (pom.xml) or Gradle (build.gradle) ' + 'project in $dir. Set a custom build command in the service.', + ); + } + steps.done('detect'); + + steps.active('compile'); + _log(LogLevel.step, 'Building $tool project in $dir …'); + _log(LogLevel.info, '\$ $exec ${args.join(' ')}'); + try { + final proc = await Process.start( + exec, + args, + workingDirectory: dir, + runInShell: true, + ); + _proc = proc; + proc.stdout + .transform(utf8.decoder) + .listen((d) => _log(LogLevel.remote, d.trimRight())); + proc.stderr + .transform(utf8.decoder) + .listen((d) => _log(LogLevel.remote, d.trimRight())); + final code = await proc.exitCode; + _proc = null; + if (code != 0) { + steps.failRemaining(); + return BuildResult(false, 'Build failed (exit code $code).'); + } + _log(LogLevel.success, 'Build finished βœ“'); + steps.done('compile'); + } catch (e) { + _proc = null; + steps.failRemaining(); + return BuildResult(false, 'Could not run build: $e'); + } + + // Locate the produced jar. + steps.active('locate'); + _log(LogLevel.step, 'Locating built jar …'); + final jar = _findJar(svc, dir, tool); + if (jar == null) { + steps.failRemaining(); + return BuildResult( + false, + 'Build succeeded but no jar was found. Set a jar pattern.', + ); + } + steps.done('locate'); + _log(LogLevel.success, 'Found jar: ${jar.path}'); + return BuildResult(true, 'Built ${_basename(jar.path)}', jar: jar); + } + + /// Builds a web app (Angular/React/Flutter web) and returns the directory of + /// static files to publish. For [WebFramework.staticFiles] no build runs. + Future buildWeb(ServiceConfig svc) async { + steps.reset([ + ('compile', 'Build ${svc.framework.label}'), + ('locate', 'Locate output'), + ]); + if (!supported) { + steps.failRemaining(); + return BuildResult(false, 'Local builds are only available on desktop.'); + } + final dir = svc.localProjectPath.trim(); + if (dir.isEmpty) { + steps.failRemaining(); + return BuildResult(false, 'No project path set.'); + } + if (!Directory(dir).existsSync()) { + steps.failRemaining(); + return BuildResult(false, 'Project path does not exist: $dir'); + } + + final command = svc.buildCommand.trim().isNotEmpty + ? svc.buildCommand.trim() + : svc.framework.defaultBuild; + + steps.active('compile'); + if (command.isNotEmpty) { + _log(LogLevel.step, 'Building ${svc.framework.label} in $dir …'); + _log(LogLevel.info, '\$ $command'); + final (exec, args) = Platform.isWindows + ? ('cmd', ['/c', command]) + : ('/bin/sh', ['-c', command]); + try { + final proc = await Process.start( + exec, + args, + workingDirectory: dir, + runInShell: true, + ); + _proc = proc; + proc.stdout + .transform(utf8.decoder) + .listen((d) => _log(LogLevel.remote, d.trimRight())); + proc.stderr + .transform(utf8.decoder) + .listen((d) => _log(LogLevel.remote, d.trimRight())); + final code = await proc.exitCode; + _proc = null; + if (code != 0) { + steps.failRemaining(); + return BuildResult(false, 'Build failed (exit $code).'); + } + _log(LogLevel.success, 'Build finished βœ“'); + } catch (e) { + _proc = null; + steps.failRemaining(); + return BuildResult(false, 'Could not run build: $e'); + } + } else { + _log(LogLevel.info, 'Static files β€” no build step.'); + } + steps.done('compile'); + + steps.active('locate'); + _log(LogLevel.step, 'Locating web output …'); + final out = _findWebOutput(svc, dir); + if (out == null) { + steps.failRemaining(); + return BuildResult( + false, + 'Could not find the built site (an index.html). ' + 'Set the output directory in the service.', + ); + } + steps.done('locate'); + _log(LogLevel.success, 'Publishing from: ${out.path}'); + return BuildResult(true, 'Built ${svc.framework.label}', dir: out); + } + + Directory? _findWebOutput(ServiceConfig svc, String dir) { + final sub = svc.outputSubdir.trim(); + if (sub.isNotEmpty) { + final d = Directory( + sub.startsWith('/') || sub.contains(':') ? sub : '$dir/$sub', + ); + if (!d.existsSync()) return null; + // If the pointed-at folder holds index.html use it; otherwise drill into + // it (e.g. user set `dist` but Angular nests dist//browser). + return _withIndex(d) ?? _findDistWithIndex(d.path) ?? d; + } + switch (svc.framework) { + case WebFramework.flutterWeb: + return _withIndex(Directory('$dir/build/web')); + case WebFramework.angular: + return _findDistWithIndex('$dir/dist'); + case WebFramework.react: + return _withIndex(Directory('$dir/build')) ?? + _withIndex(Directory('$dir/dist')); + case WebFramework.staticFiles: + return _withIndex(Directory(dir)) ?? Directory(dir); + } + } + + /// Returns [d] if it (or a nested `browser/` dir) contains index.html. + Directory? _withIndex(Directory d) { + if (!d.existsSync()) return null; + if (File('${d.path}/index.html').existsSync()) return d; + final browser = Directory('${d.path}/browser'); // Angular 17+ + if (File('${browser.path}/index.html').existsSync()) return browser; + return null; + } + + /// Angular puts output under `dist//` β€” find the one with index.html. + Directory? _findDistWithIndex(String distPath) { + final dist = Directory(distPath); + if (!dist.existsSync()) return null; + final direct = _withIndex(dist); + if (direct != null) return direct; + for (final entry in dist.listSync().whereType()) { + final found = _withIndex(entry); + if (found != null) return found; + } + return null; + } + + void cancel() { + _proc?.kill(); + _proc = null; + } + + void dispose() { + cancel(); + _logController.close(); + steps.dispose(); + } + + // ---- command resolution --------------------------------------------------- + + (String?, List, String) _resolveCommand( + ServiceConfig svc, + String dir, + ) { + final custom = svc.buildCommand.trim(); + if (custom.isNotEmpty) { + if (Platform.isWindows) { + return ('cmd', ['/c', custom], 'custom'); + } + return ('/bin/sh', ['-c', custom], 'custom'); + } + + final hasPom = File('$dir/pom.xml').existsSync(); + final hasGradle = + File('$dir/build.gradle').existsSync() || + File('$dir/build.gradle.kts').existsSync(); + + if (hasPom) { + final wrapper = _wrapper(dir, 'mvnw', 'mvnw.cmd'); + final exec = wrapper ?? 'mvn'; + return (exec, ['-B', '-DskipTests', 'package'], 'Maven'); + } + if (hasGradle) { + final wrapper = _wrapper(dir, 'gradlew', 'gradlew.bat'); + final exec = wrapper ?? 'gradle'; + return (exec, ['clean', 'build', '-x', 'test'], 'Gradle'); + } + return (null, [], ''); + } + + String? _wrapper(String dir, String unixName, String winName) { + if (Platform.isWindows) { + final w = File('$dir/$winName'); + if (w.existsSync()) return w.path; + } else { + final w = File('$dir/$unixName'); + if (w.existsSync()) return w.path; + } + return null; + } + + // ---- jar location --------------------------------------------------------- + + File? _findJar(ServiceConfig svc, String dir, String tool) { + final pattern = svc.jarPattern.trim(); + if (pattern.isNotEmpty) { + final full = pattern.startsWith('/') || pattern.contains(':') + ? pattern + : '$dir/$pattern'; + if (!full.contains('*')) { + final f = File(full); + return f.existsSync() ? f : null; + } + final slash = full.lastIndexOf('/'); + final searchDir = slash >= 0 ? full.substring(0, slash) : dir; + final glob = slash >= 0 ? full.substring(slash + 1) : full; + return _pickJar(searchDir, glob: glob); + } + + // Auto: search the tool's default output dir. For a custom command the + // tool is unknown, so try both common locations. + final candidates = switch (tool) { + 'Gradle' => ['$dir/build/libs'], + 'Maven' => ['$dir/target'], + _ => ['$dir/build/libs', '$dir/target'], + }; + for (final c in candidates) { + final jar = _pickJar(c); + if (jar != null) return jar; + } + return null; + } + + File? _pickJar(String searchDir, {String glob = '*.jar'}) { + final d = Directory(searchDir); + if (!d.existsSync()) return null; + final re = _globToRegExp(glob); + var jars = d + .listSync() + .whereType() + .where((f) => f.path.toLowerCase().endsWith('.jar')) + .where((f) => re.hasMatch(_basename(f.path))) + .toList(); + if (jars.isEmpty) return null; + + bool isArtifact(File f) { + final n = _basename(f.path).toLowerCase(); + return !n.contains('-sources') && + !n.contains('-javadoc') && + !n.startsWith('original-') && + !n.endsWith('-plain.jar'); + } + + final preferred = jars.where(isArtifact).toList(); + final pool = preferred.isNotEmpty ? preferred : jars; + pool.sort((a, b) => b.statSync().modified.compareTo(a.statSync().modified)); + return pool.first; + } + + RegExp _globToRegExp(String glob) { + final buf = StringBuffer('^'); + for (final ch in glob.split('')) { + if (ch == '*') { + buf.write('.*'); + } else if ('.[]{}()+-^\$|?\\'.contains(ch)) { + buf.write('\\$ch'); + } else { + buf.write(ch); + } + } + buf.write(r'$'); + return RegExp(buf.toString(), caseSensitive: false); + } + + static String _basename(String path) { + final i = path.replaceAll('\\', '/').lastIndexOf('/'); + return i >= 0 ? path.substring(i + 1) : path; + } +} diff --git a/lib/Peach/about/internal/file/ConnectFileStorage.dart b/lib/Peach/about/internal/file/ConnectFileStorage.dart new file mode 100644 index 0000000..a62e308 --- /dev/null +++ b/lib/Peach/about/internal/file/ConnectFileStorage.dart @@ -0,0 +1,38 @@ +import 'dart:typed_data'; + +import '../../../comms/Comms.dart' show LogLine; +import '../application/ServiceConfig.dart'; +import '../application/StepTracker.dart'; +import 'BuildService.dart'; + +/// Local-side file work: building the artifact to ship, reading it into +/// memory, and packing a web build into a single archive. Kept behind an +/// interface so a ViewModel reaches it through the same [DataManager] as +/// everything else. +abstract class ConnectFileStorage { + /// True on desktop, where local builds can run. + bool get buildSupported; + + /// Build log lines and step progress, for the console and step list. + Stream get buildLogs; + Stream> get buildStepStream; + + /// Compiles [service]'s project and locates the produced jar. + Future build(ServiceConfig service); + + /// Compiles [service]'s web project and locates its static output directory. + Future buildWeb(ServiceConfig service); + + /// Cancels a build in flight. + void cancelBuild(); + + /// Reads a local artifact into memory, ready to upload. + Future readArtifact(String path); + + /// True when [path] exists on this machine. + Future artifactExists(String path); + + /// Packs a directory of static files into gzipped tar bytes, returning the + /// archive and the number of files it contains. Runs off the UI isolate. + Future<(Uint8List, int)> packageWeb(String directoryPath); +} diff --git a/lib/Peach/about/internal/file/FileStorage.dart b/lib/Peach/about/internal/file/FileStorage.dart new file mode 100644 index 0000000..8674aeb --- /dev/null +++ b/lib/Peach/about/internal/file/FileStorage.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../../../comms/Comms.dart' show LogLine; +import '../application/ServiceConfig.dart'; +import '../application/StepTracker.dart'; +import 'BuildService.dart'; +import 'ConnectFileStorage.dart'; +import 'WebArchive.dart'; + +/// The one concrete [ConnectFileStorage]. Owns the [BuildService] and delegates +/// archiving to [WebArchive]; holds no state of its own beyond the builder. +class FileStorage implements ConnectFileStorage { + final BuildService _builder = BuildService(); + + @override + bool get buildSupported => BuildService.supported; + + @override + Stream get buildLogs => _builder.logs; + + @override + Stream> get buildStepStream => _builder.stepStream; + + @override + Future build(ServiceConfig service) => _builder.build(service); + + @override + Future buildWeb(ServiceConfig service) => + _builder.buildWeb(service); + + @override + void cancelBuild() => _builder.cancel(); + + @override + Future readArtifact(String path) => File(path).readAsBytes(); + + @override + Future artifactExists(String path) => File(path).exists(); + + @override + Future<(Uint8List, int)> packageWeb(String directoryPath) => + // Packaging a large static tree blocks long enough to drop frames, so it + // runs on a background isolate. + compute(packageWebDir, directoryPath); +} diff --git a/lib/Peach/about/internal/file/WebArchive.dart b/lib/Peach/about/internal/file/WebArchive.dart new file mode 100644 index 0000000..50e77ea --- /dev/null +++ b/lib/Peach/about/internal/file/WebArchive.dart @@ -0,0 +1,34 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive_io.dart'; + +/// Packs a directory of static files into a gzip-compressed tar archive +/// (contents at the archive root) for a single-shot upload + remote extract. +class WebArchive { + /// Returns the .tar.gz bytes of [dir]'s contents and the number of files. + static (Uint8List, int) tarGz(Directory dir) { + final archive = createArchiveFromDirectory(dir, includeDirName: false); + // CRITICAL: on Windows the entry names use backslashes; a POSIX `tar` on + // the server would then create single files literally named + // `assets\main.js` instead of the `assets/main.js` tree β€” breaking any + // app with subdirectories (Angular/React assets, etc.). Force forward + // slashes so the archive is valid on Linux. + for (final f in archive.files) { + f.name = f.name.replaceAll('\\', '/'); + } + final tar = TarEncoder().encode(archive); + final gz = GZipEncoder().encode(tar); + final count = archive.files.where((f) => f.isFile).length; + // Release the file handles opened while reading the directory so the + // source tree isn't left locked (notably on Windows). + for (final f in archive.files) { + f.closeSync(); + } + return (Uint8List.fromList(gz), count); + } +} + +/// Top-level entry so packaging can run off the UI isolate via `compute`. +(Uint8List, int) packageWebDir(String path) => + WebArchive.tarGz(Directory(path)); diff --git a/lib/Peach/comms/Comms.dart b/lib/Peach/comms/Comms.dart new file mode 100644 index 0000000..60831b7 --- /dev/null +++ b/lib/Peach/comms/Comms.dart @@ -0,0 +1,2221 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; +import '../about/external/data/Docker.dart'; +import '../about/external/data/Health.dart'; +import '../about/external/data/Kafka.dart'; +import '../about/external/data/Redis.dart'; +import '../about/internal/application/ServerProfile.dart'; +import '../about/internal/application/ServiceConfig.dart'; +import '../about/internal/application/StepTracker.dart'; +import 'ConnectComms.dart'; + +enum LogLevel { info, step, success, error, remote, warn } + +class LogLine { + final LogLevel level; + final String text; + final DateTime time; + LogLine(this.level, this.text) : time = DateTime.now(); +} + +/// Result of a deploy attempt, surfaced to the UI. +class DeployResult { + final bool ok; + final bool canRevert; + final String message; + DeployResult(this.ok, this.canRevert, this.message); +} + +/// A remote filesystem entry (for the file browser). +class RemoteEntry { + final String name; + final bool isDir; + final int size; + final int? modifyTime; + RemoteEntry(this.name, this.isDir, this.size, this.modifyTime); +} + +/// Live transfer stats for an upload or download. +class TransferProgress { + final int sent; + final int total; + final double bytesPerSec; + final Duration? eta; + final bool uploading; + const TransferProgress( + this.sent, + this.total, + this.bytesPerSec, + this.eta, + this.uploading, + ); + + double? get fraction => total <= 0 ? null : (sent / total).clamp(0.0, 1.0); + bool get complete => total > 0 && sent >= total; +} + +/// Smooths transfer speed with an exponential moving average and throttles +/// emissions to ~10/s. +class _SpeedMeter { + final _start = DateTime.now(); + DateTime _lastEmit; + DateTime _lastT; + int _lastSent = 0; + double _bps = 0; + _SpeedMeter() + : _lastEmit = DateTime.now().subtract(const Duration(seconds: 1)), + _lastT = DateTime.now(); + + /// Updates speed; returns true if the caller should emit now. + bool tick(int sent, {bool force = false}) { + final now = DateTime.now(); + final dt = now.difference(_lastT).inMicroseconds / 1e6; + if (dt >= 0.15) { + final inst = (sent - _lastSent) / dt; + _bps = _bps == 0 ? inst : _bps * 0.6 + inst * 0.4; + _lastT = now; + _lastSent = sent; + } else if (_bps == 0 && sent > 0) { + final el = now.difference(_start).inMicroseconds / 1e6; + if (el > 0) _bps = sent / el; + } + if (force || now.difference(_lastEmit).inMilliseconds >= 100) { + _lastEmit = now; + return true; + } + return false; + } + + double get bps => _bps; + Duration? eta(int sent, int total) { + if (_bps <= 0 || total <= 0) return null; + final remaining = (total - sent) / _bps; + return Duration(milliseconds: (remaining * 1000).round()); + } +} + +/// Drives one SSH session against a server: safe jar deployment, log +/// streaming, and generic SFTP file transfer. +class SshService implements ConnectComms { + SSHClient? _client; + String? _connectedServerId; + bool _rootUser = false; // when logged in as root, skip the sudo wrapper + SSHSession? _logSession; + @override + bool get isConnected => _client != null; + + // Revert state after a deploy that left a backup in place. + bool _canRevert = false; + bool _revertIsWeb = false; + bool _cancelled = false; + String? _revertTarget; + String? _revertBackup; + ServerProfile? _revertServer; + ServiceConfig? _revertService; + @override + bool get canRevert => _canRevert; + + final _logController = StreamController.broadcast(); + @override + Stream get logs => _logController.stream; + + final _progressController = StreamController.broadcast(); + @override + Stream get transferProgress => _progressController.stream; + + final steps = StepTracker(); + @override + Stream> get stepStream => steps.stream; + String? _uploadStepKey; // step whose progress the SFTP upload feeds + + void _log(LogLevel level, String text) { + for (final line in const LineSplitter().convert(text)) { + if (line.trim().isEmpty && level == LogLevel.remote) continue; + _logController.add(LogLine(level, line)); + } + } + + Future _connect(ServerProfile s) async { + // Reuse only a live connection to the same server. A long build between + // batch deploys can let the server drop an idle connection β€” detect that + // (isClosed) and reconnect instead of using a dead client. + if (_client != null && _connectedServerId == s.id && !_client!.isClosed) { + return; + } + if (_client != null) disconnect(); + _log(LogLevel.step, 'Connecting to ${s.username}@${s.host}:${s.port} …'); + final socket = await SSHSocket.connect( + s.host, + s.port, + timeout: const Duration(seconds: 15), + ); + final client = SSHClient( + socket, + username: s.username, + onPasswordRequest: () => s.password, + ); + await client.authenticated; + _client = client; + _connectedServerId = s.id; + _rootUser = s.username.trim().toLowerCase() == 'root'; + _log( + LogLevel.success, + 'Connected βœ“${_rootUser ? ' (root β€” running without sudo)' : ''}', + ); + } + + /// Public connect used by the file browser. + @override + Future connect(ServerProfile s) => _connect(s); + + /// Runs a command, streaming stdout/stderr to the log. Feeds the sudo + /// password over stdin when [sudo] is set. Returns the exit code. + Future _run( + String command, { + bool sudo = false, + String? password, + LogLevel echo = LogLevel.remote, + }) async { + final client = _client!; + final useSudo = sudo && !_rootUser; + final cmd = useSudo + ? "sudo -S -p '' bash -c ${_shQuote(command)}" + : command; + final session = await client.execute(cmd); + if (useSudo && password != null) { + session.stdin.add(Uint8List.fromList(utf8.encode('$password\n'))); + } + await session.stdin.close(); + await Future.wait([ + session.stdout + .cast>() + .transform(utf8.decoder) + .forEach((d) => _log(echo, d.trimRight())), + session.stderr + .cast>() + .transform(utf8.decoder) + .forEach((d) => _log(echo, d.trimRight())), + ]); + await session.done; + return session.exitCode ?? -1; + } + + Future _capture( + String command, { + bool sudo = false, + String? password, + }) async { + final client = _client!; + final useSudo = sudo && !_rootUser; + final cmd = useSudo + ? "sudo -S -p '' bash -c ${_shQuote(command)}" + : command; + final session = await client.execute(cmd); + if (useSudo && password != null) { + session.stdin.add(Uint8List.fromList(utf8.encode('$password\n'))); + } + await session.stdin.close(); + final out = StringBuffer(); + await Future.wait([ + session.stdout + .cast>() + .transform(utf8.decoder) + .forEach(out.write), + session.stderr.cast>().transform(utf8.decoder).drain(), + ]); + await session.done; + return out.toString().trim(); + } + + static String _shQuote(String s) => "'${s.replaceAll("'", "'\\''")}'"; + + // ---- jar deployment ------------------------------------------------------- + + @override + Future deploy({ + required ServerProfile server, + required ServiceConfig service, + required Uint8List jarBytes, + required String jarLabel, + }) async { + _canRevert = false; + _revertIsWeb = false; + _cancelled = false; + final sudo = server.useSudo; + final pw = server.password; + final hasService = service.serviceName.trim().isNotEmpty; + steps.reset([ + ('connect', 'Connect'), + ('upload', 'Upload jar'), + ('stop', 'Stop service'), + ('backup', 'Back up current'), + ('install', 'Install jar'), + ('start', 'Start & verify'), + ('cleanup', 'Finalize'), + ('logs', 'Fetch logs'), + ]); + try { + steps.active('connect'); + await _connect(server); + steps.done('connect'); + + final target = service.remotePath(jarLabel); + final backup = '$target.bak'; + final dir = target.substring(0, target.lastIndexOf('/')); + final staging = + '/tmp/peachdeploy-${DateTime.now().millisecondsSinceEpoch}.jar'; + + await _run('mkdir -p ${_shQuote(dir)}', sudo: sudo, password: pw); + + // 1. Upload to /tmp first β€” the live jar is untouched until success. + steps.active('upload'); + _uploadStepKey = 'upload'; + _log( + LogLevel.step, + 'Uploading $jarLabel (${_fmtBytes(jarBytes.length)}) β†’ $staging', + ); + await _sftpUpload(jarBytes, staging); + _uploadStepKey = null; + steps.done('upload'); + _log(LogLevel.success, 'Upload complete βœ“'); + + // 2. Stop the service before swapping. + steps.active('stop'); + if (hasService) { + _log(LogLevel.step, 'Stopping service "${service.serviceName}" …'); + await _run( + 'systemctl stop ${_shQuote(service.serviceName)}', + sudo: sudo, + password: pw, + ); + _log(LogLevel.success, 'Service stopped βœ“'); + steps.done('stop'); + } else { + steps.skip('stop'); + } + + // 3. Back up the current jar to .bak. + steps.active('backup'); + _log(LogLevel.step, 'Backing up current jar β†’ $backup'); + await _run( + 'rm -f ${_shQuote(backup)}; ' + 'if [ -f ${_shQuote(target)} ]; then mv ${_shQuote(target)} ${_shQuote(backup)}; ' + 'echo "backed up"; else echo "no previous jar"; fi', + sudo: sudo, + password: pw, + ); + steps.done('backup'); + + // 4. Install the new jar under the required name. + steps.active('install'); + _log(LogLevel.step, 'Installing β†’ $target'); + await _run( + 'mv -f ${_shQuote(staging)} ${_shQuote(target)}', + sudo: sudo, + password: pw, + ); + if (service.isGeneric) { + await _run('chmod +x ${_shQuote(target)}', sudo: sudo, password: pw); + } + // Verify the artifact actually landed (SFTP/mv can fail on a dropped + // connection during a long batch). + final placed = await _capture( + 'if [ -f ${_shQuote(target)} ]; then echo "OK \$(wc -c < ${_shQuote(target)})"; else echo MISSING; fi', + sudo: sudo, + password: pw, + ); + _log(LogLevel.remote, 'artifact check: $placed'); + if (placed.contains('MISSING')) { + steps.failRemaining(); + // Restore the previous jar so the service isn't left broken. + await _run( + 'if [ -f ${_shQuote(backup)} ]; then mv -f ${_shQuote(backup)} ${_shQuote(target)}; fi', + sudo: sudo, + password: pw, + ); + _log(LogLevel.error, 'Artifact not present at $target after install.'); + return DeployResult( + false, + false, + 'Install failed β€” jar not present at $target.', + ); + } + steps.done('install'); + _log(LogLevel.success, 'New artifact installed βœ“'); + + // 5. Start and verify. + steps.active('start'); + var ok = true; + if (hasService) { + _log(LogLevel.step, 'Starting service "${service.serviceName}" …'); + await _run( + 'systemctl start ${_shQuote(service.serviceName)}', + sudo: sudo, + password: pw, + ); + await Future.delayed(const Duration(seconds: 2)); + final state = await _capture( + 'systemctl is-active ${_shQuote(service.serviceName)} || true', + sudo: sudo, + password: pw, + ); + ok = state.contains('active') && !state.contains('inactive'); + if (ok) { + _log(LogLevel.success, 'Service is active βœ“'); + } else { + _log(LogLevel.error, 'Service is "$state" β€” start FAILED.'); + await _run( + 'systemctl --no-pager status ${_shQuote(service.serviceName)} | head -n 12', + sudo: sudo, + password: pw, + ); + } + } + ok ? steps.done('start') : steps.fail('start'); + + steps.active('cleanup'); + if (ok) { + await _run('rm -f ${_shQuote(backup)}', sudo: sudo, password: pw); + _log(LogLevel.success, 'Deployment successful πŸŽ‰'); + _canRevert = false; + steps.done('cleanup'); + } else { + _canRevert = true; + _revertTarget = target; + _revertBackup = backup; + _revertServer = server; + _revertService = service; + _log( + LogLevel.warn, + 'Backup preserved at $backup β€” you can revert to the previous jar.', + ); + steps.skip('cleanup'); + } + + steps.active('logs'); + _log(LogLevel.step, 'Fetching recent logs …'); + await _runLogCommand(server, service); + steps.done('logs'); + + return DeployResult( + ok, + _canRevert, + ok + ? 'Deployed $jarLabel and service is active.' + : 'Service failed to start.', + ); + } catch (e) { + steps.failRemaining(); + if (_cancelled) { + _log(LogLevel.warn, 'Deployment cancelled by user.'); + return DeployResult(false, _canRevert, 'Deployment cancelled.'); + } + _log(LogLevel.error, 'Deploy failed: $e'); + return DeployResult(false, _canRevert, 'Error: $e'); + } + } + + // ---- web (nginx) deployment ---------------------------------------------- + + /// Deploys a static site (Angular/React/Flutter web/static) to the nginx web + /// root. [archiveBytes] is a .tar.gz of the built site's contents. + @override + Future deployWeb({ + required ServerProfile server, + required ServiceConfig service, + required Uint8List archiveBytes, + required int fileCount, + }) async { + _canRevert = false; + _revertIsWeb = true; + _cancelled = false; + final sudo = server.useSudo; + final pw = server.password; + final root = service.remoteRoot; + final backup = '$root.bak'; + final nginx = service.nginxService.trim().isEmpty + ? 'nginx' + : service.nginxService.trim(); + final ts = DateTime.now().millisecondsSinceEpoch; + final archive = '/tmp/peachdeploy-web-$ts.tgz'; + final staging = '/tmp/peachweb-$ts'; // extract & verify here first + steps.reset([ + ('connect', 'Connect'), + ('upload', 'Upload bundle'), + ('extract', 'Extract & verify'), + ('backup', 'Back up web root'), + ('publish', 'Publish $fileCount files'), + ('reload', 'Reload nginx'), + ('cleanup', 'Finalize'), + ('logs', 'Fetch logs'), + ]); + try { + steps.active('connect'); + await _connect(server); + steps.done('connect'); + + // Ensure the web root (e.g. /var/www/) exists β€” create it and + // any parent folders if missing. + _log(LogLevel.step, 'Ensuring web root $root exists …'); + await _run('mkdir -p ${_shQuote(root)}', sudo: sudo, password: pw); + + // 1. Upload the bundle to /tmp. + steps.active('upload'); + _uploadStepKey = 'upload'; + _log( + LogLevel.step, + 'Uploading site bundle (${_fmtBytes(archiveBytes.length)}, $fileCount files) …', + ); + await _sftpUpload(archiveBytes, archive); + _uploadStepKey = null; + steps.done('upload'); + _log(LogLevel.success, 'Upload complete βœ“'); + + // 2. Extract to a STAGING dir and verify BEFORE touching the live root, + // so a bad/empty bundle can never wipe the site. + steps.active('extract'); + _log(LogLevel.step, 'Extracting bundle to $staging …'); + await _run( + 'rm -rf ${_shQuote(staging)} && mkdir -p ${_shQuote(staging)} && ' + 'tar -xzf ${_shQuote(archive)} -C ${_shQuote(staging)}', + sudo: sudo, + password: pw, + ); + final verify = await _capture( + 'if [ -f ${_shQuote('$staging/index.html')} ]; then ' + 'echo "OK \$(find ${_shQuote(staging)} -type f | wc -l)"; ' + 'else echo "MISSING \$(find ${_shQuote(staging)} -type f | wc -l)"; fi', + sudo: sudo, + password: pw, + ); + _log(LogLevel.remote, 'staging check: $verify'); + if (!verify.contains('OK')) { + steps.failRemaining(); + await _run( + 'rm -rf ${_shQuote(staging)} ${_shQuote(archive)}', + sudo: sudo, + password: pw, + ); + _log( + LogLevel.error, + 'No index.html in the bundle β€” the LIVE site at $root was NOT touched. ' + 'Check the build output folder (must be the folder that contains index.html, ' + 'e.g. dist//browser).', + ); + return DeployResult( + false, + false, + 'Bundle has no index.html β€” nothing published (site untouched).', + ); + } + steps.done('extract'); + + // 3. Back up the current web root (only now that we know staging is good). + steps.active('backup'); + _log(LogLevel.step, 'Backing up web root β†’ $backup'); + await _run( + 'mkdir -p ${_shQuote(root)}; rm -rf ${_shQuote(backup)}; ' + 'if [ -d ${_shQuote(root)} ] && [ -n "\$(ls -A ${_shQuote(root)} 2>/dev/null)" ]; ' + 'then cp -a ${_shQuote(root)} ${_shQuote(backup)}; fi', + sudo: sudo, + password: pw, + ); + steps.done('backup'); + + // 4. Atomic-ish swap: clear the root and copy staging contents in. + steps.active('publish'); + _log(LogLevel.step, 'Publishing new site to $root …'); + final publishCode = await _run( + 'find ${_shQuote(root)} -mindepth 1 -delete && ' + 'cp -a ${_shQuote('$staging/.')} ${_shQuote(root)}/ && ' + 'rm -rf ${_shQuote(staging)} ${_shQuote(archive)}', + sudo: sudo, + password: pw, + ); + final live = await _capture( + 'if [ -f ${_shQuote('$root/index.html')} ]; then echo "OK \$(find ${_shQuote(root)} -type f | wc -l)"; else echo MISSING; fi', + sudo: sudo, + password: pw, + ); + _log(LogLevel.remote, 'live check: $live'); + if (publishCode != 0 || live.contains('MISSING')) { + // Restore the backup so the site is never left empty. + _log(LogLevel.warn, 'Publish issue β€” restoring previous site …'); + await _run( + 'find ${_shQuote(root)} -mindepth 1 -delete; ' + 'if [ -d ${_shQuote(backup)} ]; then cp -a ${_shQuote('$backup/.')} ${_shQuote(root)}/; fi', + sudo: sudo, + password: pw, + ); + steps.failRemaining(); + return DeployResult( + false, + false, + 'Publish failed β€” restored the previous site.', + ); + } + steps.done('publish'); + final listing = await _capture( + 'ls -la ${_shQuote(root)} | head -n 25', + sudo: sudo, + password: pw, + ); + _log(LogLevel.success, 'Published to $root:'); + _log(LogLevel.remote, listing); + + // 4. Test config and reload nginx. + steps.active('reload'); + var ok = true; + _log(LogLevel.step, 'Testing nginx config & reloading …'); + final test = await _capture( + 'nginx -t 2>&1 || true', + sudo: sudo, + password: pw, + ); + _log(LogLevel.remote, test); + if (test.contains('test failed') || test.contains('emerg')) { + ok = false; + _log(LogLevel.error, 'nginx config test FAILED.'); + } else { + await _run( + 'systemctl reload ${_shQuote(nginx)}', + sudo: sudo, + password: pw, + ); + await Future.delayed(const Duration(seconds: 1)); + final state = await _capture( + 'systemctl is-active ${_shQuote(nginx)} || true', + sudo: sudo, + password: pw, + ); + ok = state.contains('active') && !state.contains('inactive'); + _log( + ok ? LogLevel.success : LogLevel.error, + ok ? 'nginx reloaded βœ“' : 'nginx is "$state".', + ); + } + ok ? steps.done('reload') : steps.fail('reload'); + + steps.active('cleanup'); + if (ok) { + await _run('rm -rf ${_shQuote(backup)}', sudo: sudo, password: pw); + _log(LogLevel.success, 'Website deployed πŸŽ‰'); + _canRevert = false; + steps.done('cleanup'); + } else { + _canRevert = true; + _revertTarget = root; + _revertBackup = backup; + _revertServer = server; + _revertService = service; + _log(LogLevel.warn, 'Previous site kept at $backup β€” you can revert.'); + steps.skip('cleanup'); + } + + steps.active('logs'); + await _runLogCommand(server, service); + steps.done('logs'); + + return DeployResult( + ok, + _canRevert, + ok ? 'Published $fileCount files to $root.' : 'nginx failed to reload.', + ); + } catch (e) { + steps.failRemaining(); + if (_cancelled) { + _log(LogLevel.warn, 'Deployment cancelled by user.'); + return DeployResult(false, _canRevert, 'Deployment cancelled.'); + } + _log(LogLevel.error, 'Web deploy failed: $e'); + return DeployResult(false, _canRevert, 'Error: $e'); + } + } + + /// Best-effort cancel of an in-flight deploy: interrupts the current command + /// by dropping the connection. Steps already applied on the server are not + /// rolled back automatically. + @override + void requestCancel() { + _cancelled = true; + _log(LogLevel.warn, 'Cancelling β€” closing connection …'); + try { + _client?.close(); + } catch (_) {} + _client = null; + _connectedServerId = null; + } + + /// Restarts a service (or reloads nginx for web apps) without deploying, + /// then shows recent logs. Returns success. + @override + Future restartOnly( + ServerProfile server, + ServiceConfig service, + ) async { + final sudo = server.useSudo; + final pw = server.password; + try { + await _connect(server); + if (service.isWeb) { + final nginx = service.nginxService.trim().isEmpty + ? 'nginx' + : service.nginxService.trim(); + _log(LogLevel.step, 'Reloading $nginx …'); + await _run( + 'systemctl reload ${_shQuote(nginx)}', + sudo: sudo, + password: pw, + ); + final state = await _capture( + 'systemctl is-active ${_shQuote(nginx)} || true', + sudo: sudo, + password: pw, + ); + final ok = state.contains('active') && !state.contains('inactive'); + _log( + ok ? LogLevel.success : LogLevel.error, + ok ? 'nginx reloaded βœ“' : 'nginx is "$state".', + ); + await _runLogCommand(server, service); + return DeployResult( + ok, + false, + ok ? 'nginx reloaded.' : 'Reload failed.', + ); + } + final unit = service.serviceName.trim(); + if (unit.isEmpty) { + return DeployResult( + false, + false, + 'No systemd unit set for this service.', + ); + } + _log(LogLevel.step, 'Restarting $unit …'); + await _run( + 'systemctl restart ${_shQuote(unit)}', + sudo: sudo, + password: pw, + ); + await Future.delayed(const Duration(seconds: 2)); + final state = await _capture( + 'systemctl is-active ${_shQuote(unit)} || true', + sudo: sudo, + password: pw, + ); + final ok = state.contains('active') && !state.contains('inactive'); + _log( + ok ? LogLevel.success : LogLevel.error, + ok ? 'Service is active βœ“' : 'Service is "$state".', + ); + await _runLogCommand(server, service); + return DeployResult( + ok, + false, + ok ? 'Service restarted.' : 'Restart failed.', + ); + } catch (e) { + _log(LogLevel.error, 'Restart failed: $e'); + return DeployResult(false, false, 'Error: $e'); + } + } + + @override + Future revert() async { + final server = _revertServer; + final service = _revertService; + final target = _revertTarget; + final backup = _revertBackup; + if (server == null || + service == null || + target == null || + backup == null || + !_canRevert) { + return DeployResult(false, false, 'Nothing to revert.'); + } + final sudo = server.useSudo; + final pw = server.password; + try { + if (_revertIsWeb) { + _log(LogLevel.step, 'Reverting web root to previous site …'); + await _run( + 'if [ -d ${_shQuote(backup)} ]; then ' + 'find ${_shQuote(target)} -mindepth 1 -delete; ' + 'cp -a ${_shQuote('$backup/.')} ${_shQuote(target)}/; ' + 'rm -rf ${_shQuote(backup)}; ' + 'else echo "no backup to restore"; fi', + sudo: sudo, + password: pw, + ); + final nginx = service.nginxService.trim().isEmpty + ? 'nginx' + : service.nginxService.trim(); + await _run( + 'systemctl reload ${_shQuote(nginx)}', + sudo: sudo, + password: pw, + ); + _log(LogLevel.success, 'Reverted to previous site βœ“'); + } else { + _log(LogLevel.step, 'Reverting to previous jar …'); + if (service.serviceName.trim().isNotEmpty) { + await _run( + 'systemctl stop ${_shQuote(service.serviceName)}', + sudo: sudo, + password: pw, + ); + } + await _run('rm -f ${_shQuote(target)}', sudo: sudo, password: pw); + await _run( + 'mv -f ${_shQuote(backup)} ${_shQuote(target)}', + sudo: sudo, + password: pw, + ); + if (service.serviceName.trim().isNotEmpty) { + await _run( + 'systemctl start ${_shQuote(service.serviceName)}', + sudo: sudo, + password: pw, + ); + } + _log(LogLevel.success, 'Reverted to previous jar βœ“'); + } + _canRevert = false; + await _runLogCommand(server, service); + return DeployResult(true, false, 'Reverted to the previous version.'); + } catch (e) { + _log(LogLevel.error, 'Revert failed: $e'); + return DeployResult(false, true, 'Revert error: $e'); + } + } + + @override + Future discardBackup() async { + final server = _revertServer; + final backup = _revertBackup; + if (server == null || backup == null) return; + _log(LogLevel.step, 'Discarding backup $backup …'); + final cmd = _revertIsWeb + ? 'rm -rf ${_shQuote(backup)}' + : 'rm -f ${_shQuote(backup)}'; + await _run(cmd, sudo: server.useSudo, password: server.password); + _canRevert = false; + _log(LogLevel.success, 'Backup discarded.'); + } + + // ---- logs ----------------------------------------------------------------- + + Future _runLogCommand( + ServerProfile server, + ServiceConfig service, + ) async { + await _run( + service.effectiveLogCommand, + sudo: server.useSudo, + password: server.password, + echo: LogLevel.remote, + ); + } + + /// Writes and registers a systemd unit file: upload β†’ move to + /// /etc/systemd/system β†’ daemon-reload β†’ (enable) β†’ (start) β†’ status. + @override + Future installSystemdUnit({ + required ServerProfile server, + required String unitName, + required String unitContent, + bool enable = true, + bool startNow = true, + }) async { + final sudo = server.useSudo; + final pw = server.password; + final tmp = + '/tmp/peachdeploy-$unitName-${DateTime.now().millisecondsSinceEpoch}.service'; + final target = '/etc/systemd/system/$unitName.service'; + steps.reset([ + ('connect', 'Connect'), + ('upload', 'Upload unit file'), + ('install', 'Install unit'), + ('reload', 'daemon-reload'), + ('enable', 'Enable on boot'), + ('start', 'Start service'), + ('status', 'Check status'), + ]); + try { + steps.active('connect'); + await _connect(server); + steps.done('connect'); + + steps.active('upload'); + _log(LogLevel.step, 'Uploading unit file β†’ $tmp'); + await _sftpUpload(Uint8List.fromList(utf8.encode(unitContent)), tmp); + steps.done('upload'); + + steps.active('install'); + _log(LogLevel.step, 'Installing β†’ $target'); + await _run( + 'mv -f ${_shQuote(tmp)} ${_shQuote(target)} && chmod 644 ${_shQuote(target)}', + sudo: sudo, + password: pw, + ); + steps.done('install'); + + steps.active('reload'); + await _run('systemctl daemon-reload', sudo: sudo, password: pw); + steps.done('reload'); + + steps.active('enable'); + if (enable) { + await _run( + 'systemctl enable ${_shQuote(unitName)}', + sudo: sudo, + password: pw, + ); + steps.done('enable'); + } else { + steps.skip('enable'); + } + + steps.active('start'); + if (startNow) { + await _run( + 'systemctl restart ${_shQuote(unitName)}', + sudo: sudo, + password: pw, + ); + await Future.delayed(const Duration(seconds: 2)); + steps.done('start'); + } else { + steps.skip('start'); + } + + steps.active('status'); + final st = await _capture( + 'systemctl is-active ${_shQuote(unitName)} || true', + sudo: sudo, + password: pw, + ); + await _run( + 'systemctl --no-pager status ${_shQuote(unitName)} | head -n 12', + sudo: sudo, + password: pw, + ); + steps.done('status'); + + final ok = + !startNow || (st.contains('active') && !st.contains('inactive')); + _log( + ok ? LogLevel.success : LogLevel.warn, + 'Unit $unitName installed${startNow ? ' β€” is "$st"' : ''}.', + ); + return DeployResult( + ok, + false, + ok + ? '$unitName registered${startNow ? ' and active' : ''}.' + : '$unitName installed but is "$st".', + ); + } catch (e) { + steps.failRemaining(); + _log(LogLevel.error, 'Unit install failed: $e'); + return DeployResult(false, false, 'Error: $e'); + } + } + + // ---- environment (configuration tab) -------------------------------------- + + /// Detects the server's package manager: apt | dnf | yum | apk | zypper | + /// pacman | unknown. + @override + Future detectPackageManager(ServerProfile server) async { + await _connect(server); + final out = await _capture( + 'command -v apt-get >/dev/null 2>&1 && echo apt; ' + 'command -v dnf >/dev/null 2>&1 && echo dnf; ' + 'command -v yum >/dev/null 2>&1 && echo yum; ' + 'command -v apk >/dev/null 2>&1 && echo apk; ' + 'command -v zypper >/dev/null 2>&1 && echo zypper; ' + 'command -v pacman >/dev/null 2>&1 && echo pacman', + sudo: false, + ); + final lines = out + .trim() + .split('\n') + .where((l) => l.trim().isNotEmpty) + .toList(); + return lines.isEmpty ? 'unknown' : lines.first.trim(); + } + + /// Runs each detect command; returns key -> version line ('' when missing). + @override + Future> probeEnvironment( + ServerProfile server, + List detectByKey, + ) async { + // detectByKey is a flat list of "keycommand" pairs to avoid importing + // the model here. + await _connect(server); + final res = {}; + for (final pair in detectByKey) { + final i = pair.indexOf(''); + final key = pair.substring(0, i); + final cmd = pair.substring(i + 1); + try { + res[key] = (await _capture(cmd, sudo: false)).trim(); + } catch (_) { + res[key] = ''; + } + } + return res; + } + + /// Installs a set of packages (one combined command) and enables each + /// listed systemd unit. Used by the new-server setup wizard. + @override + Future installEnvironment({ + required ServerProfile server, + required String command, + required List units, + }) async { + final sudo = server.useSudo; + final pw = server.password; + steps.reset([ + ('connect', 'Connect'), + ('install', 'Install packages'), + ('enable', 'Enable services'), + ('verify', 'Finalize'), + ]); + try { + steps.active('connect'); + await _connect(server); + steps.done('connect'); + + steps.active('install'); + _log(LogLevel.step, '\$ $command'); + final code = await _run(command, sudo: sudo, password: pw); + if (code != 0) { + steps.failRemaining(); + return DeployResult(false, false, 'Install failed (exit $code).'); + } + steps.done('install'); + + steps.active('enable'); + for (final u in units.where((u) => u.trim().isNotEmpty)) { + _log(LogLevel.step, 'Enabling $u …'); + await _run( + 'systemctl enable --now ${_shQuote(u)} || true', + sudo: sudo, + password: pw, + ); + } + steps.done('enable'); + + steps.active('verify'); + steps.done('verify'); + _log(LogLevel.success, 'Environment ready βœ“'); + return DeployResult(true, false, 'Environment set up.'); + } catch (e) { + steps.failRemaining(); + _log(LogLevel.error, 'Setup failed: $e'); + return DeployResult(false, false, 'Error: $e'); + } + } + + /// Runs a guided install command (already confirmed by the user) and + /// optionally enables a systemd unit afterwards. + @override + Future installPackages({ + required ServerProfile server, + required String command, + String? enableUnit, + }) async { + final sudo = server.useSudo; + final pw = server.password; + steps.reset([ + ('connect', 'Connect'), + ('install', 'Install packages'), + ('enable', 'Enable service'), + ('verify', 'Finalize'), + ]); + try { + steps.active('connect'); + await _connect(server); + steps.done('connect'); + + steps.active('install'); + _log(LogLevel.step, '\$ $command'); + final code = await _run(command, sudo: sudo, password: pw); + if (code != 0) { + steps.failRemaining(); + _log(LogLevel.error, 'Install failed (exit $code).'); + return DeployResult(false, false, 'Install failed (exit $code).'); + } + steps.done('install'); + + steps.active('enable'); + if (enableUnit != null && enableUnit.trim().isNotEmpty) { + await _run( + 'systemctl enable --now ${_shQuote(enableUnit)} || true', + sudo: sudo, + password: pw, + ); + steps.done('enable'); + } else { + steps.skip('enable'); + } + + steps.active('verify'); + steps.done('verify'); + _log(LogLevel.success, 'Installation complete βœ“'); + return DeployResult(true, false, 'Installed successfully.'); + } catch (e) { + steps.failRemaining(); + _log(LogLevel.error, 'Install failed: $e'); + return DeployResult(false, false, 'Error: $e'); + } + } + + /// Probes a service's health per its configured [HealthCheckType]. Quiet: + /// does not stream to the log console (used by the health dashboard). + @override + Future checkHealth( + ServerProfile server, + ServiceConfig service, + ) async { + final pw = server.password; + final sudo = server.useSudo; + try { + await _connect(server); + switch (service.healthType) { + case HealthCheckType.none: + return HealthResult(HealthStatus.disabled, 'disabled'); + case HealthCheckType.auto: + final unit = _unitFor(service); + if (unit.isEmpty) { + return HealthResult(HealthStatus.unknown, 'no unit'); + } + final out = await _capture( + 'systemctl is-active ${_shQuote(unit)} || true', + sudo: false, + ); + final s = out.trim().split('\n').last.trim(); + final ok = s.contains('active') && !s.contains('inactive'); + return HealthResult( + ok ? HealthStatus.healthy : HealthStatus.unhealthy, + s.isEmpty ? 'unknown' : s, + ); + case HealthCheckType.http: + final url = service.healthTarget.trim(); + if (url.isEmpty) { + return HealthResult(HealthStatus.unknown, 'no URL'); + } + final out = await _capture( + 'curl -s -o /dev/null -w "%{http_code}" --max-time 6 ${_shQuote(url)} 2>/dev/null || echo 000', + sudo: false, + ); + final code = int.tryParse(out.trim().split('\n').last.trim()) ?? 0; + final ok = code >= 200 && code < 400; + return HealthResult( + ok ? HealthStatus.healthy : HealthStatus.unhealthy, + 'HTTP $code', + ); + case HealthCheckType.tcp: + final port = service.healthTarget.trim(); + if (port.isEmpty) { + return HealthResult(HealthStatus.unknown, 'no port'); + } + final out = await _capture( + 'timeout 4 bash -c "echo > /dev/tcp/127.0.0.1/$port" >/dev/null 2>&1 && echo open || echo closed', + sudo: false, + ); + final ok = out.contains('open'); + return HealthResult( + ok ? HealthStatus.healthy : HealthStatus.unhealthy, + ok ? 'port $port open' : 'port $port closed', + ); + case HealthCheckType.command: + final cmd = service.healthTarget.trim(); + if (cmd.isEmpty) { + return HealthResult(HealthStatus.unknown, 'no command'); + } + final out = await _capture( + '($cmd) >/dev/null 2>&1 && echo OK || echo FAIL', + sudo: sudo, + password: pw, + ); + final ok = out.contains('OK'); + return HealthResult( + ok ? HealthStatus.healthy : HealthStatus.unhealthy, + ok ? 'exit 0' : 'non-zero exit', + ); + } + } catch (e) { + return HealthResult(HealthStatus.unknown, 'error: $e'); + } + } + + // ---- redis viewer --------------------------------------------------------- + + String _redisCli({ + required String host, + required int port, + required String pass, + required int db, + }) { + // `timeout 8` guards against a hung connect; --no-auth-warning only when a + // password is passed (older redis-cli rejects the flag otherwise). + final b = StringBuffer('timeout 8 redis-cli'); + b.write(' -h ${_shQuote(host.trim().isEmpty ? '127.0.0.1' : host.trim())}'); + b.write(' -p $port'); + if (pass.trim().isNotEmpty) { + b.write(' -a ${_shQuote(pass.trim())} --no-auth-warning'); + } + if (db != 0) b.write(' -n $db'); + return b.toString(); + } + + /// (ok, detail) β€” verifies redis-cli exists and PING returns PONG. + @override + Future<(bool, String)> redisPing( + ServerProfile server, { + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) async { + await _connect(server); + final has = await _capture( + 'command -v redis-cli >/dev/null 2>&1 && echo yes || echo no', + sudo: false, + ); + if (has.contains('no')) { + return (false, 'redis-cli is not installed on the server.'); + } + final out = await _capture( + '${_redisCli(host: host, port: port, pass: pass, db: db)} PING 2>&1', + sudo: false, + ); + final ok = out.trim().toUpperCase().contains('PONG'); + return (ok, out.trim()); + } + + /// Reads `requirepass` from the server's Redis config (needs root/sudo to + /// read /etc/redis). Returns '' if none is set / not found. + @override + Future redisConfigPassword(ServerProfile server) async { + await _connect(server); + final line = await _capture( + 'for f in /etc/redis/redis.conf /etc/redis.conf /etc/redis/redis-server.conf ' + '/etc/redis/6379.conf; do [ -f "\$f" ] && grep -iE "^[[:space:]]*requirepass[[:space:]]" "\$f"; done | tail -n1', + sudo: server.useSudo, + password: server.password, + ); + final m = RegExp( + r'requirepass\s+(.+)', + caseSensitive: false, + ).firstMatch(line.trim()); + if (m == null) return ''; + return m.group(1)!.trim().replaceAll('"', '').replaceAll("'", ''); + } + + /// Total number of keys in the selected DB (`DBSIZE`). + @override + Future redisDbSize( + ServerProfile server, { + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) async { + await _connect(server); + final out = await _capture( + '${_redisCli(host: host, port: port, pass: pass, db: db)} DBSIZE 2>&1', + sudo: false, + ); + return int.tryParse( + RegExp(r'\d+').firstMatch(out.trim())?.group(0) ?? '', + ) ?? + 0; + } + + /// Returns (number of DBs, {dbIndex: keyCount}) from CONFIG + INFO keyspace. + @override + Future<(int, Map)> redisDatabases( + ServerProfile server, { + String host = '127.0.0.1', + int port = 6379, + String pass = '', + }) async { + await _connect(server); + final cli = _redisCli(host: host, port: port, pass: pass, db: 0); + final ks = await _capture('$cli INFO keyspace 2>&1', sudo: false); + final counts = {}; + for (final line in ks.split('\n')) { + final m = RegExp(r'^db(\d+):keys=(\d+)').firstMatch(line.trim()); + if (m != null) counts[int.parse(m.group(1)!)] = int.parse(m.group(2)!); + } + final cfg = await _capture('$cli CONFIG GET databases 2>&1', sudo: false); + final nums = RegExp( + r'\d+', + ).allMatches(cfg).map((m) => m.group(0)!).toList(); + final dbCount = nums.isEmpty ? 16 : int.tryParse(nums.last) ?? 16; + return (dbCount, counts); + } + + /// Scans keys returning (key, type) pairs, optionally filtered to + /// [filterType]. Uses a server-side Lua SCAN+TYPE (one round trip); scan is + /// capped at [limit] keys. Returns [] on error (caller falls back). + @override + Future> redisKeysTyped( + ServerProfile server, { + String pattern = '*', + String filterType = '', + int limit = 50000, + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) async { + await _connect(server); + final cli = _redisCli(host: host, port: port, pass: pass, db: db); + const script = + 'local cur="0" local out={} local n=0 repeat local r=redis.call("SCAN",cur,"MATCH",ARGV[1],"COUNT",500) cur=r[1] for i=1,#r[2] do local k=r[2][i] local t=redis.call("TYPE",k).ok if ARGV[3]=="" or t==ARGV[3] then out[#out+1]=k out[#out+1]=t end n=n+1 if n>=tonumber(ARGV[2]) then cur="0" break end end until cur=="0" return out'; + final out = await _capture( + "$cli EVAL '$script' 0 ${_shQuote(pattern)} ${_shQuote(limit.toString())} ${_shQuote(filterType)} 2>&1", + sudo: false, + ); + final lines = out + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + if (lines.isEmpty || + out.contains('(error)') || + out.toUpperCase().contains('ERR ') || + out.contains('NOAUTH')) { + return []; + } + final result = <(String, String)>[]; + for (var i = 0; i + 1 < lines.length; i += 2) { + result.add((lines[i], lines[i + 1])); + } + return result; + } + + /// Lists keys matching [pattern] (SCAN-based; capped at [limit] for safety). + @override + Future> redisKeys( + ServerProfile server, { + String pattern = '*', + int limit = 100000, + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) async { + await _connect(server); + final cli = _redisCli(host: host, port: port, pass: pass, db: db); + final out = await _capture( + '$cli --scan --pattern ${_shQuote(pattern)} 2>/dev/null | head -n $limit', + sudo: false, + ); + return out + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + } + + /// Reads a key's type, TTL and value (formatted per type). + @override + Future redisGet( + ServerProfile server, + String key, { + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) async { + await _connect(server); + final cli = _redisCli(host: host, port: port, pass: pass, db: db); + final k = _shQuote(key); + final type = (await _capture('$cli TYPE $k 2>&1', sudo: false)).trim(); + final ttl = (await _capture('$cli TTL $k 2>&1', sudo: false)).trim(); + final readCmd = switch (type) { + 'list' => '$cli LRANGE $k 0 -1', + 'set' => '$cli SMEMBERS $k', + 'hash' => '$cli HGETALL $k', + 'zset' => '$cli ZRANGE $k 0 -1 WITHSCORES', + 'stream' => '$cli XRANGE $k - +', + 'string' => '$cli GET $k', + _ => '$cli GET $k', + }; + final value = (await _capture('$readCmd 2>&1', sudo: false)).trim(); + return RedisEntry(key: key, type: type, ttl: ttl, value: value); + } + + // ---- docker --------------------------------------------------------------- + + @override + Future dockerStatus(ServerProfile server) async { + await _connect(server); + final has = await _capture( + 'command -v docker >/dev/null 2>&1 && echo yes || echo no', + sudo: false, + ); + if (has.contains('no')) return DockerStatus(false, false, ''); + final ver = (await _capture( + 'docker --version 2>/dev/null', + sudo: false, + )).trim(); + final info = await _capture( + 'docker info >/dev/null 2>&1 && echo up || echo down', + sudo: server.useSudo, + password: server.password, + ); + return DockerStatus(true, info.contains('up'), ver); + } + + static const _dockerSep = '~|~'; + + @override + Future> dockerPs( + ServerProfile server, { + bool all = true, + }) async { + await _connect(server); + final fmt = + '{{.ID}}$_dockerSep{{.Names}}$_dockerSep{{.Image}}$_dockerSep{{.State}}$_dockerSep{{.Status}}$_dockerSep{{.Ports}}'; + final out = await _capture( + "docker ps ${all ? '-a' : ''} --format ${_shQuote(fmt)} 2>&1", + sudo: server.useSudo, + password: server.password, + ); + final list = []; + for (final line in out.split('\n')) { + final l = line.trim(); + if (l.isEmpty) continue; + final parts = l.split(_dockerSep); + if (parts.length < 6) continue; // skip errors/noise + list.add( + DockerContainer( + id: parts[0], + name: parts[1], + image: parts[2], + state: parts[3], + status: parts[4], + ports: parts[5], + ), + ); + } + return list; + } + + /// action: start | stop | restart | rm + @override + Future dockerAction( + ServerProfile server, + String id, + String action, + ) async { + await _connect(server); + final cmd = action == 'rm' + ? 'docker rm -f ${_shQuote(id)}' + : 'docker $action ${_shQuote(id)}'; + return (await _capture( + '$cmd 2>&1', + sudo: server.useSudo, + password: server.password, + )).trim(); + } + + @override + Future dockerLogs( + ServerProfile server, + String id, { + int tail = 300, + }) async { + await _connect(server); + return (await _capture( + 'docker logs --tail $tail ${_shQuote(id)} 2>&1', + sudo: server.useSudo, + password: server.password, + )).trim(); + } + + @override + Future dockerTest(ServerProfile server) async { + await _connect(server); + return (await _capture( + 'docker run --rm hello-world 2>&1', + sudo: server.useSudo, + password: server.password, + )).trim(); + } + + /// Builds and runs `docker run`. Returns (ok, output). + @override + Future<(bool, String)> dockerRun( + ServerProfile server, + DockerRunSpec spec, + ) async { + await _connect(server); + final b = StringBuffer('docker run'); + b.write(spec.detached ? ' -d' : ' --rm'); + if (spec.name.trim().isNotEmpty) { + b.write(' --name ${_shQuote(spec.name.trim())}'); + } + if (spec.restart.trim().isNotEmpty && spec.detached) { + b.write(' --restart ${_shQuote(spec.restart.trim())}'); + } + for (final p in spec.portList) { + b.write(' -p ${_shQuote(p)}'); + } + for (final e in spec.envList) { + b.write(' -e ${_shQuote(e)}'); + } + for (final v in spec.volumeList) { + b.write(' -v ${_shQuote(v)}'); + } + b.write(' ${_shQuote(spec.image.trim())}'); + if (spec.command.trim().isNotEmpty) b.write(' ${spec.command.trim()}'); + + _log(LogLevel.step, '\$ ${b.toString()}'); + final out = await _capture( + '${b.toString()} 2>&1', + sudo: server.useSudo, + password: server.password, + ); + final t = out.trim(); + // Success: docker prints the (64-hex) container id; errors contain text. + final ok = + RegExp( + r'^[0-9a-f]{12,64}$', + multiLine: true, + ).hasMatch(t.split('\n').last.trim()) && + !t.toLowerCase().contains('error'); + _log(ok ? LogLevel.success : LogLevel.error, t); + return (ok, t); + } + + // ---- kafka monitor -------------------------------------------------------- + + /// Full path to a Kafka CLI script, single-quoted for the shell. + String _kbin(String binDir, String suffix, String name) => + _shQuote('$binDir/$name$suffix'); + + List _ints(String s) => s + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .map((e) => int.tryParse(e) ?? -1) + .where((e) => e >= 0) + .toList(); + + /// Locates the Kafka tooling (bin dir + script suffix) on the server. + @override + Future kafkaDetect(ServerProfile server) async { + await _connect(server); + final java = await _capture( + 'command -v java >/dev/null 2>&1 && echo yes || echo no', + sudo: false, + ); + // Prefer PATH, then fall back to the usual install locations. + const finder = + 'p=""; ' + 'for c in kafka-topics.sh kafka-topics; do ' + 'if command -v "\$c" >/dev/null 2>&1; then p=\$(command -v "\$c"); break; fi; done; ' + 'if [ -z "\$p" ]; then ' + 'for d in /opt/kafka/bin /usr/local/kafka/bin /opt/bitnami/kafka/bin ' + '/home/kafka/kafka/bin /usr/share/kafka/bin /opt/confluent/bin ' + '/usr/lib/kafka/bin \$(ls -d /opt/kafka*/bin 2>/dev/null) ' + '\$(ls -d /opt/kafka_*/bin 2>/dev/null) \$(ls -d /usr/local/kafka*/bin 2>/dev/null); do ' + 'for s in kafka-topics.sh kafka-topics; do ' + 'if [ -x "\$d/\$s" ]; then p="\$d/\$s"; break 2; fi; done; done; fi; ' + 'echo "\$p"'; + final lines = (await _capture( + finder, + sudo: false, + )).split('\n').map((l) => l.trim()).where((l) => l.isNotEmpty).toList(); + final path = lines.isEmpty ? '' : lines.last; + if (path.isEmpty) { + return KafkaStatus( + detected: false, + java: java.contains('yes'), + detail: + 'kafka-topics not found on PATH or in the common install directories ' + '(/opt/kafka, /usr/local/kafka, /opt/bitnami/kafka …).', + ); + } + final slash = path.lastIndexOf('/'); + final binDir = slash >= 0 ? path.substring(0, slash) : '.'; + final base = slash >= 0 ? path.substring(slash + 1) : path; + final suffix = base.endsWith('.sh') ? '.sh' : ''; + final ver = (await _capture( + 'timeout 15 ${_shQuote(path)} --version 2>/dev/null | head -n1', + sudo: false, + )).trim(); + return KafkaStatus( + detected: true, + binDir: binDir, + suffix: suffix, + java: java.contains('yes'), + version: ver, + detail: 'Found $path', + ); + } + + /// Connectivity probe β€” returns the broker-api-versions output (or the error). + @override + Future kafkaTest( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }) async { + await _connect(server); + final out = await _capture( + 'timeout 25 ${_kbin(binDir, suffix, 'kafka-broker-api-versions')} ' + '--bootstrap-server ${_shQuote(bootstrap)} 2>&1 | head -n 30', + sudo: false, + ); + return out.trim(); + } + + /// topic:partition -> offset, for `--time` -1 (latest) or -2 (earliest). + Future> _kafkaOffsets( + String binDir, + String suffix, + String bootstrap, + int time, + ) async { + var out = await _capture( + 'timeout 25 ${_kbin(binDir, suffix, 'kafka-get-offsets')} ' + '--bootstrap-server ${_shQuote(bootstrap)} --time $time 2>/dev/null', + sudo: false, + ); + if (out.trim().isEmpty) { + // Older Kafka: fall back to the GetOffsetShell tool. + out = await _capture( + 'timeout 25 ${_kbin(binDir, suffix, 'kafka-run-class')} ' + 'kafka.tools.GetOffsetShell --broker-list ${_shQuote(bootstrap)} ' + '--time $time 2>/dev/null', + sudo: false, + ); + } + final map = {}; + for (final raw in out.split('\n')) { + final m = RegExp(r'^([^:]+):(\d+):(-?\d+)$').firstMatch(raw.trim()); + if (m != null) { + map['${m.group(1)}:${m.group(2)}'] = int.tryParse(m.group(3)!) ?? 0; + } + } + return map; + } + + /// Lists every topic with its partitions, replication and message offsets. + @override + Future> kafkaTopics( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + bool includeInternal = false, + }) async { + await _connect(server); + final describe = await _capture( + 'timeout 25 ${_kbin(binDir, suffix, 'kafka-topics')} ' + '--bootstrap-server ${_shQuote(bootstrap)} --describe 2>/dev/null', + sudo: false, + ); + + final topics = []; + List curParts = []; + for (final raw in describe.split('\n')) { + final line = raw.trim(); + if (line.isEmpty) continue; + if (line.contains('PartitionCount:')) { + final name = RegExp(r'Topic:\s*(\S+)').firstMatch(line)?.group(1) ?? ''; + final rf = + int.tryParse( + RegExp( + r'ReplicationFactor:\s*(\d+)', + ).firstMatch(line)?.group(1) ?? + '', + ) ?? + 0; + final cfg = + RegExp(r'Configs:\s*(.*)').firstMatch(line)?.group(1)?.trim() ?? ''; + curParts = []; + topics.add( + KafkaTopic( + name: name, + replicationFactor: rf, + partitions: curParts, + configs: cfg, + ), + ); + } else if (line.contains('Partition:') && line.contains('Leader:')) { + final pid = + int.tryParse( + RegExp(r'Partition:\s*(\d+)').firstMatch(line)?.group(1) ?? '', + ) ?? + 0; + final leader = + int.tryParse( + RegExp(r'Leader:\s*(-?\d+)').firstMatch(line)?.group(1) ?? '', + ) ?? + -1; + final replicas = _ints( + RegExp(r'Replicas:\s*([0-9,]*)').firstMatch(line)?.group(1) ?? '', + ); + final isr = _ints( + RegExp(r'Isr:\s*([0-9,]*)').firstMatch(line)?.group(1) ?? '', + ); + curParts.add( + KafkaPartition(id: pid, leader: leader, replicas: replicas, isr: isr), + ); + } + } + + // Overlay message offsets (best effort β€” topics still render without them). + final latest = await _kafkaOffsets(binDir, suffix, bootstrap, -1); + final earliest = await _kafkaOffsets(binDir, suffix, bootstrap, -2); + for (final t in topics) { + for (final p in t.partitions) { + p.latest = latest['${t.name}:${p.id}'] ?? p.latest; + p.earliest = earliest['${t.name}:${p.id}'] ?? p.earliest; + } + } + + final result = includeInternal + ? topics + : topics.where((t) => !t.internal).toList(); + result.sort((a, b) => a.name.compareTo(b.name)); + return result; + } + + /// Splits a Kafka CLI table row on runs of 2+ spaces (columns are padded). + List _cols(String line) => line + .split(RegExp(r'\s{2,}')) + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + + /// Lists consumer groups with state, members and per-partition lag. + @override + Future> kafkaGroups( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }) async { + await _connect(server); + final cg = _kbin(binDir, suffix, 'kafka-consumer-groups'); + final bs = _shQuote(bootstrap); + + final groups = {}; + + // 1) All group ids (so idle/empty groups still appear). + final listOut = await _capture( + 'timeout 25 $cg --bootstrap-server $bs --list 2>/dev/null', + sudo: false, + ); + for (final raw in listOut.split('\n')) { + final id = raw.trim(); + if (id.isEmpty || id.contains(' ')) continue; + groups[id] = KafkaConsumerGroup(groupId: id); + } + + // 2) State / members / coordinator per group. + final stateOut = await _capture( + 'timeout 30 $cg --bootstrap-server $bs --describe --all-groups --state 2>/dev/null', + sudo: false, + ); + for (final raw in stateOut.split('\n')) { + final line = raw.trim(); + if (line.isEmpty || line.startsWith('GROUP')) continue; + final c = _cols(line); + if (c.length < 4) continue; + final members = int.tryParse(c.last); + if (members == null) continue; // not a data row + final state = c[c.length - 2]; + final id = c[0]; + final g = groups.putIfAbsent(id, () => KafkaConsumerGroup(groupId: id)); + g.state = state; + g.members = members; + } + + // 3) Offsets / lag per topic-partition. + final offOut = await _capture( + 'timeout 40 $cg --bootstrap-server $bs --describe --all-groups --offsets 2>/dev/null', + sudo: false, + ); + for (final raw in offOut.split('\n')) { + final line = raw.trim(); + if (line.isEmpty || line.startsWith('GROUP')) continue; + final c = _cols(line); + if (c.length < 6) continue; + final partition = int.tryParse(c[2]); + if (partition == null) continue; // header/noise + int num(String s) => int.tryParse(s.trim()) ?? 0; + final g = groups.putIfAbsent( + c[0], + () => KafkaConsumerGroup(groupId: c[0]), + ); + g.assignments.add( + KafkaGroupAssignment( + topic: c[1], + partition: partition, + currentOffset: num(c[3]), + logEndOffset: num(c[4]), + lag: num(c[5]), + consumerId: c.length > 6 ? c[6] : '', + host: c.length > 7 ? c[7] : '', + clientId: c.length > 8 ? c[8] : '', + ), + ); + } + + final list = groups.values.toList() + ..sort((a, b) => a.groupId.compareTo(b.groupId)); + return list; + } + + /// Cluster snapshot: brokers, cluster id, controller and health counts. + @override + Future kafkaCluster( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }) async { + await _connect(server); + final bs = _shQuote(bootstrap); + + // Brokers via broker-api-versions (one broker per header line). + final apiOut = await _capture( + 'timeout 25 ${_kbin(binDir, suffix, 'kafka-broker-api-versions')} ' + '--bootstrap-server $bs 2>/dev/null', + sudo: false, + ); + final brokers = []; + for (final raw in apiOut.split('\n')) { + final m = RegExp( + r'^(\S+):(\d+)\s+\(id:\s*(\d+)\s+rack:\s*(\S+)\)', + ).firstMatch(raw.trim()); + if (m != null) { + final rack = m.group(4)!; + brokers.add( + KafkaBroker( + id: int.parse(m.group(3)!), + host: m.group(1)!, + port: int.parse(m.group(2)!), + rack: rack == 'null' ? '' : rack, + ), + ); + } + } + brokers.sort((a, b) => a.id.compareTo(b.id)); + + // Cluster id (best effort). + String clusterId = ''; + final cid = await _capture( + 'timeout 20 ${_kbin(binDir, suffix, 'kafka-cluster')} cluster-id ' + '--bootstrap-server $bs 2>/dev/null', + sudo: false, + ); + clusterId = + RegExp(r'Cluster ID:\s*(\S+)').firstMatch(cid)?.group(1)?.trim() ?? ''; + + // Controller / quorum leader (KRaft) β€” best effort. + int controllerId = -1; + final quorum = await _capture( + 'timeout 20 ${_kbin(binDir, suffix, 'kafka-metadata-quorum')} ' + '--bootstrap-server $bs describe --status 2>/dev/null', + sudo: false, + ); + controllerId = + int.tryParse( + RegExp(r'LeaderId:\s*(\d+)').firstMatch(quorum)?.group(1) ?? '', + ) ?? + -1; + for (final b in brokers) { + if (b.id == controllerId) b.isController = true; + } + + // Health: under-replicated & unavailable partitions. + List parsePartitions(String out) { + final res = []; + for (final raw in out.split('\n')) { + final line = raw.trim(); + final t = RegExp(r'Topic:\s*(\S+)').firstMatch(line)?.group(1); + final p = RegExp(r'Partition:\s*(\d+)').firstMatch(line)?.group(1); + if (t != null && p != null) res.add('$t-$p'); + } + return res; + } + + final ur = await _capture( + 'timeout 25 ${_kbin(binDir, suffix, 'kafka-topics')} ' + '--bootstrap-server $bs --describe --under-replicated-partitions 2>/dev/null', + sudo: false, + ); + final un = await _capture( + 'timeout 25 ${_kbin(binDir, suffix, 'kafka-topics')} ' + '--bootstrap-server $bs --describe --unavailable-partitions 2>/dev/null', + sudo: false, + ); + + return KafkaCluster( + brokers: brokers, + clusterId: clusterId, + controllerId: controllerId, + underReplicated: parsePartitions(ur), + unavailable: parsePartitions(un), + ); + } + + String _unitFor(ServiceConfig s) => s.isWeb + ? (s.nginxService.trim().isEmpty ? 'nginx' : s.nginxService.trim()) + : s.serviceName.trim(); + + static String _fmtDt(DateTime d) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${d.year}-${two(d.month)}-${two(d.day)} ' + '${two(d.hour)}:${two(d.minute)}:${two(d.second)}'; + } + + /// Public one-shot log fetch (connects if needed). Optionally restricts to a + /// time window via journalctl `--since` / `--until` (when using the default + /// journalctl command). + @override + Future fetchLogs( + ServerProfile server, + ServiceConfig service, { + DateTime? since, + DateTime? until, + }) async { + await _connect(server); + _log(LogLevel.step, 'Fetching logs for ${service.name} …'); + final hasRange = since != null || until != null; + if (hasRange && service.logCommand.trim().isEmpty) { + final unit = _unitFor(service).isEmpty ? 'app' : _unitFor(service); + final b = StringBuffer( + 'journalctl -u ${_shQuote(unit)} -n 2000 --no-pager', + ); + if (since != null) b.write(' --since ${_shQuote(_fmtDt(since))}'); + if (until != null) b.write(' --until ${_shQuote(_fmtDt(until))}'); + await _run( + b.toString(), + sudo: server.useSudo, + password: server.password, + echo: LogLevel.remote, + ); + } else { + await _runLogCommand(server, service); + } + } + + /// The distinct `root` directives from the live nginx config (`nginx -T`), + /// so the deploy screen can warn if the web root doesn't match what nginx + /// actually serves. Returns [] if it can't be read. + @override + Future> nginxRoots(ServerProfile server) async { + try { + await _connect(server); + final out = await _capture( + "nginx -T 2>/dev/null | grep -E '^[[:space:]]*root[[:space:]]' | " + "sed -E 's/^[[:space:]]*root[[:space:]]+//; s/;.*//; s/[[:space:]]+\$//' | sort -u", + sudo: server.useSudo, + password: server.password, + ); + return out + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + } catch (_) { + return []; + } + } + + /// Current systemd state of the service's unit ('active', 'inactive', + /// 'failed', 'activating', 'unknown', or 'no-unit'). + @override + Future serviceStatus( + ServerProfile server, + ServiceConfig service, + ) async { + await _connect(server); + final unit = _unitFor(service); + if (unit.isEmpty) return 'no-unit'; + final out = await _capture( + 'systemctl is-active ${_shQuote(unit)} || true', + sudo: server.useSudo, + password: server.password, + ); + final s = out.trim().split('\n').last.trim(); + return s.isEmpty ? 'unknown' : s; + } + + /// start | stop | restart the service's unit (reload for nginx web apps). + @override + Future serviceControl( + ServerProfile server, + ServiceConfig service, + String action, + ) async { + final unit = _unitFor(service); + if (unit.isEmpty) { + return DeployResult( + false, + false, + 'No systemd unit set for this service.', + ); + } + final sudo = server.useSudo; + final pw = server.password; + // nginx should reload, not restart, on "restart". + final effective = (service.isWeb && action == 'restart') + ? 'reload' + : action; + try { + await _connect(server); + _log(LogLevel.step, 'systemctl $effective $unit …'); + await _run( + 'systemctl $effective ${_shQuote(unit)}', + sudo: sudo, + password: pw, + ); + await Future.delayed(const Duration(seconds: 2)); + final st = await serviceStatus(server, service); + final ok = action == 'stop' + ? (st.contains('inactive') || + st.contains('dead') || + st.contains('failed')) + : (st.contains('active') && !st.contains('inactive')); + _log(ok ? LogLevel.success : LogLevel.warn, '$unit is now "$st".'); + await _runLogCommand(server, service); + return DeployResult(ok, false, '$unit is $st.'); + } catch (e) { + _log(LogLevel.error, 'Control failed: $e'); + return DeployResult(false, false, 'Error: $e'); + } + } + + @override + Future followLogs(ServerProfile server, ServiceConfig service) async { + await _connect(server); + stopLogs(); + final unit = service.isWeb + ? (service.nginxService.isEmpty ? 'nginx' : service.nginxService) + : (service.serviceName.isEmpty ? 'app' : service.serviceName); + final base = 'journalctl -u ${_shQuote(unit)} -n 50 -f'; + final useSudo = server.useSudo && !_rootUser; + final cmd = useSudo ? "sudo -S -p '' $base" : base; + _log(LogLevel.step, 'Live tailing logs (press stop to end) …'); + final session = await _client!.execute(cmd); + _logSession = session; + if (useSudo) { + session.stdin.add( + Uint8List.fromList(utf8.encode('${server.password}\n')), + ); + } + await session.stdin.close(); + unawaited( + session.stdout + .cast>() + .transform(utf8.decoder) + .forEach((d) => _log(LogLevel.remote, d.trimRight())), + ); + unawaited( + session.stderr + .cast>() + .transform(utf8.decoder) + .forEach((d) => _log(LogLevel.remote, d.trimRight())), + ); + } + + @override + void stopLogs() { + _logSession?.close(); + _logSession = null; + } + + // ---- generic SFTP file transfer ------------------------------------------ + + @override + Future> listDir(ServerProfile server, String path) async { + await _connect(server); + final sftp = await _client!.sftp(); + final names = await sftp.listdir(path.isEmpty ? '.' : path); + final entries = []; + for (final n in names) { + if (n.filename == '.' || n.filename == '..') continue; + entries.add( + RemoteEntry( + n.filename, + n.attr.isDirectory, + n.attr.size ?? 0, + n.attr.modifyTime, + ), + ); + } + entries.sort((a, b) { + if (a.isDir != b.isDir) return a.isDir ? -1 : 1; + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + return entries; + } + + @override + Future resolveHome(ServerProfile server) async { + await _connect(server); + final sftp = await _client!.sftp(); + try { + return await sftp.absolute('.'); + } catch (_) { + return '.'; + } + } + + @override + Future uploadFile( + ServerProfile server, + Uint8List bytes, + String remotePath, + ) async { + await _connect(server); + await _sftpUpload(bytes, remotePath); + } + + @override + Future downloadFile( + ServerProfile server, + String remotePath, + ) async { + await _connect(server); + final sftp = await _client!.sftp(); + final file = await sftp.open(remotePath); + final attrs = await file.stat(); + final total = attrs.size ?? 0; + final builder = BytesBuilder(copy: false); + final meter = _SpeedMeter(); + await for (final chunk in file.read()) { + builder.add(chunk); + if (meter.tick(builder.length)) { + _emitTransfer(builder.length, total, meter, uploading: false); + } + } + await file.close(); + _emitTransfer( + total == 0 ? builder.length : total, + total == 0 ? builder.length : total, + meter, + uploading: false, + force: true, + ); + return builder.toBytes(); + } + + void _emitTransfer( + int sent, + int total, + _SpeedMeter meter, { + required bool uploading, + bool force = false, + }) { + final tp = TransferProgress( + sent, + total, + meter.bps, + meter.eta(sent, total), + uploading, + ); + _progressController.add(tp); + if (_uploadStepKey != null) { + steps.progress(_uploadStepKey!, tp.fraction); + } + } + + @override + Future deleteRemote( + ServerProfile server, + String remotePath, + bool isDir, + ) async { + await _connect(server); + final sftp = await _client!.sftp(); + if (isDir) { + await sftp.rmdir(remotePath); + } else { + await sftp.remove(remotePath); + } + } + + Future _sftpUpload(Uint8List bytes, String remotePath) async { + final sftp = await _client!.sftp(); + final file = await sftp.open( + remotePath, + mode: + SftpFileOpenMode.create | + SftpFileOpenMode.write | + SftpFileOpenMode.truncate, + ); + final total = bytes.length; + var sent = 0; + const chunk = 32 * 1024; + final meter = _SpeedMeter(); + final stream = + Stream.fromIterable([ + for (var i = 0; i < total; i += chunk) + Uint8List.sublistView( + bytes, + i, + i + chunk > total ? total : i + chunk, + ), + ]).map((c) { + sent += c.length; + if (meter.tick(sent)) { + _emitTransfer(sent, total, meter, uploading: true); + } + return c; + }); + await file.write(stream); + await file.close(); + _emitTransfer(total, total, meter, uploading: true, force: true); + } + + // ---- lifecycle ------------------------------------------------------------ + + @override + void disconnect() { + stopLogs(); + _client?.close(); + _client = null; + _connectedServerId = null; + _log(LogLevel.info, 'Disconnected.'); + } + + @override + void dispose() { + stopLogs(); + _client?.close(); + _client = null; + _connectedServerId = null; + _logController.close(); + _progressController.close(); + steps.dispose(); + } + + static String _fmtBytes(int b) { + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(1)} KB'; + return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} diff --git a/lib/Peach/comms/ConnectComms.dart b/lib/Peach/comms/ConnectComms.dart new file mode 100644 index 0000000..aa62fb8 --- /dev/null +++ b/lib/Peach/comms/ConnectComms.dart @@ -0,0 +1,223 @@ +import 'dart:typed_data'; + +import '../about/external/data/Docker.dart'; +import '../about/external/data/Health.dart'; +import '../about/external/data/Kafka.dart'; +import '../about/external/data/Redis.dart'; +import '../about/internal/application/ServerProfile.dart'; +import '../about/internal/application/ServiceConfig.dart'; +import '../about/internal/application/StepTracker.dart'; +import 'Comms.dart'; + +/// The transport contract: everything Peach can ask a remote server to do. +/// In the Autoreceptive reference this is the HTTP surface; here the wire is +/// SSH, but the role is identical β€” one abstract description of every remote +/// call, implemented by exactly one class ([SshService]) and reached only +/// through the [DataManager]. +abstract class ConnectComms { + // ── session ─────────────────────────────────────────────────────────────── + bool get isConnected; + bool get canRevert; + Stream get logs; + Stream get transferProgress; + Stream> get stepStream; + + Future connect(ServerProfile s); + void requestCancel(); + void disconnect(); + void dispose(); + + // ── deployment ──────────────────────────────────────────────────────────── + Future deploy({ + required ServerProfile server, + required ServiceConfig service, + required Uint8List jarBytes, + required String jarLabel, + }); + + Future deployWeb({ + required ServerProfile server, + required ServiceConfig service, + required Uint8List archiveBytes, + required int fileCount, + }); + + Future restartOnly(ServerProfile server, ServiceConfig service); + + Future revert(); + + Future discardBackup(); + + // ── provisioning ────────────────────────────────────────────────────────── + Future installSystemdUnit({ + required ServerProfile server, + required String unitName, + required String unitContent, + bool enable = true, + bool startNow = true, + }); + + Future detectPackageManager(ServerProfile server); + + Future> probeEnvironment( + ServerProfile server, + List detectByKey, + ); + + Future installEnvironment({ + required ServerProfile server, + required String command, + required List units, + }); + + Future installPackages({ + required ServerProfile server, + required String command, + String? enableUnit, + }); + + // ── service & health ────────────────────────────────────────────────────── + Future checkHealth(ServerProfile server, ServiceConfig service); + + Future serviceStatus(ServerProfile server, ServiceConfig service); + + Future serviceControl( + ServerProfile server, + ServiceConfig service, + String action, + ); + + Future> nginxRoots(ServerProfile server); + + // ── logs ────────────────────────────────────────────────────────────────── + Future fetchLogs( + ServerProfile server, + ServiceConfig service, { + DateTime? since, + DateTime? until, + }); + + Future followLogs(ServerProfile server, ServiceConfig service); + + void stopLogs(); + + // ── redis ───────────────────────────────────────────────────────────────── + Future<(bool, String)> redisPing( + ServerProfile server, { + String host, + int port, + String pass, + int db, + }); + + Future redisConfigPassword(ServerProfile server); + + Future redisDbSize( + ServerProfile server, { + String host, + int port, + String pass, + int db, + }); + + Future<(int, Map)> redisDatabases( + ServerProfile server, { + String host, + int port, + String pass, + }); + + Future> redisKeysTyped( + ServerProfile server, { + String pattern, + String filterType, + int limit, + String host, + int port, + String pass, + int db, + }); + + Future> redisKeys( + ServerProfile server, { + String pattern, + int limit, + String host, + int port, + String pass, + int db, + }); + + Future redisGet( + ServerProfile server, + String key, { + String host, + int port, + String pass, + int db, + }); + + // ── docker ──────────────────────────────────────────────────────────────── + Future dockerStatus(ServerProfile server); + + Future> dockerPs(ServerProfile server, {bool all}); + + Future dockerAction(ServerProfile server, String id, String action); + + Future dockerLogs(ServerProfile server, String id, {int tail}); + + Future dockerTest(ServerProfile server); + + Future<(bool, String)> dockerRun(ServerProfile server, DockerRunSpec spec); + + // ── kafka ───────────────────────────────────────────────────────────────── + Future kafkaDetect(ServerProfile server); + + Future kafkaTest( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }); + + Future> kafkaTopics( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + bool includeInternal, + }); + + Future> kafkaGroups( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }); + + Future kafkaCluster( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }); + + // ── remote filesystem ───────────────────────────────────────────────────── + Future> listDir(ServerProfile server, String path); + + Future resolveHome(ServerProfile server); + + Future uploadFile( + ServerProfile server, + Uint8List bytes, + String remotePath, + ); + + Future downloadFile(ServerProfile server, String remotePath); + + Future deleteRemote( + ServerProfile server, + String remotePath, + bool isDir, + ); +} diff --git a/lib/Peach/configs/Navigator.dart b/lib/Peach/configs/Navigator.dart new file mode 100644 index 0000000..7faad8d --- /dev/null +++ b/lib/Peach/configs/Navigator.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:page_transition/page_transition.dart'; + +import '../about/internal/application/NavigatorType.dart'; + +/// Every screen transition in the app funnels through here. Destinations are +/// widget instances, not route names β€” there is no route table. Keeping one +/// choke point means a guard (session, device, connectivity) can be applied to +/// all routing by editing a single method. +class PeachNavigation { + void navigateToPage(NavigatorType type, dynamic path, BuildContext context) { + _runSec(context, type, path); + } + + Future _runSec(BuildContext context, NavigatorType type, dynamic path) async { + switch (type) { + case NavigatorType.openFully: + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (BuildContext context) => path), + ); + break; + case NavigatorType.justOpen: + Navigator.push( + context, + PageTransition( + type: PageTransitionType.size, + alignment: Alignment.center, + opaque: true, + child: path, + ), + ); + break; + case NavigatorType.replaceCurrent: + Navigator.pushReplacement( + context, + PageTransition( + type: PageTransitionType.scale, + alignment: Alignment.center, + opaque: true, + curve: Curves.ease, + duration: const Duration(microseconds: 9000), + child: path, + ), + ); + break; + case NavigatorType.makeNewMain: + Navigator.pushAndRemoveUntil( + context, + PageTransition( + type: PageTransitionType.fade, + alignment: Alignment.center, + opaque: true, + child: path, + ), + ModalRoute.withName('/'), + ); + break; + } + } + + /// Opens [path] and returns whatever it pops with β€” the reload-on-return + /// channel: a child calls `Navigator.pop(context, true)` and the parent + /// refreshes on a truthy result. + Future navigateToPageWithData( + dynamic path, + BuildContext context, + ) async { + return await Navigator.push( + context, + MaterialPageRoute(builder: (context) => path), + ); + } +} diff --git a/lib/Peach/configs/ThemeController.dart b/lib/Peach/configs/ThemeController.dart new file mode 100644 index 0000000..b746f91 --- /dev/null +++ b/lib/Peach/configs/ThemeController.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +/// App-wide theme mode. `main.dart` listens to this and rebuilds MaterialApp; +/// any screen can set it. Persisting the choice is the ViewModel's job β€” this +/// only carries the live value. +class ThemeController { + ThemeController._(); + + static final ValueNotifier mode = ValueNotifier( + ThemeMode.dark, + ); + + /// The stored string ('dark' | 'light' | 'system') for the current mode. + static String get name => mode.value.name; + + static void apply(String stored) { + mode.value = switch (stored) { + 'light' => ThemeMode.light, + 'system' => ThemeMode.system, + _ => ThemeMode.dark, + }; + } +} diff --git a/lib/Peach/designs/ActivityChart.dart b/lib/Peach/designs/ActivityChart.dart new file mode 100644 index 0000000..982ee05 --- /dev/null +++ b/lib/Peach/designs/ActivityChart.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import '../about/internal/application/TextType.dart'; + +import '../about/internal/application/DeploymentRecord.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +enum _Range { week, month, year } + +class _Bucket { + final String label; + final int count; + _Bucket(this.label, this.count); +} + +/// "Compute Activity" style card: a bar chart of deployment counts over time, +/// with the peak column highlighted, and a Week / Month / Year toggle. +class ActivityChart extends StatefulWidget { + final List history; + const ActivityChart({super.key, required this.history}); + + @override + State createState() => _ActivityChartState(); +} + +class _ActivityChartState extends State { + _Range _range = _Range.week; + + List<_Bucket> _buckets() { + final now = DateTime.now(); + final h = widget.history; + int between(DateTime a, DateTime b) => + h.where((r) => !r.time.isBefore(a) && r.time.isBefore(b)).length; + const wd = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const mo = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + switch (_range) { + case _Range.week: + final today = DateTime(now.year, now.month, now.day); + return List.generate(7, (i) { + final d = today.subtract(Duration(days: 6 - i)); + return _Bucket( + wd[d.weekday - 1], + between(d, d.add(const Duration(days: 1))), + ); + }); + case _Range.month: + // last 6 weeks + final startOfWeek = DateTime( + now.year, + now.month, + now.day, + ).subtract(Duration(days: now.weekday - 1)); + return List.generate(6, (i) { + final s = startOfWeek.subtract(Duration(days: (5 - i) * 7)); + final e = s.add(const Duration(days: 7)); + return _Bucket('${s.month}/${s.day}', between(s, e)); + }); + case _Range.year: + // last 6 months + return List.generate(6, (i) { + final m = DateTime(now.year, now.month - (5 - i), 1); + final next = DateTime(m.year, m.month + 1, 1); + return _Bucket(mo[m.month - 1], between(m, next)); + }); + } + } + + @override + Widget build(BuildContext context) { + final jp = context.jp; + final buckets = _buckets(); + final max = buckets.fold(0, (m, b) => b.count > m ? b.count : m); + final peakIdx = buckets.indexWhere((b) => b.count == max && max > 0); + final total = buckets.fold(0, (n, b) => n + b.count); + return Container( + padding: const EdgeInsets.all(18), + decoration: PeachTheme.panel(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + 'Deployment activity', + 16, + TextType.Bold, + color: jp.text, + weight: FontWeight.w800, + ), + const SizedBox(height: 2), + text( + '$total deploys this period', + 12.5, + TextType.Regular, + color: jp.textDim, + weight: FontWeight.w400, + ), + ], + ), + ), + _toggle(jp), + ], + ), + const SizedBox(height: 20), + SizedBox( + height: 190, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (var i = 0; i < buckets.length; i++) + Expanded(child: _bar(jp, buckets[i], max, i == peakIdx)), + ], + ), + ), + ], + ), + ); + } + + Widget _bar(JpColors jp, _Bucket b, int max, bool peak) { + final frac = max == 0 ? 0.0 : b.count / max; + final h = 12 + frac * 130; + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + text( + '${b.count}', + 12, + TextType.Regular, + color: peak ? PeachTheme.seed : jp.textDim, + weight: FontWeight.w800, + ), + const SizedBox(height: 6), + Container( + width: 26, + height: h, + decoration: BoxDecoration( + gradient: peak ? PeachTheme.brandGradient : null, + color: peak ? null : jp.surface2, + borderRadius: BorderRadius.circular(9), + boxShadow: peak + ? PeachTheme.glow(PeachTheme.seed, opacity: 0.35, blur: 16) + : null, + ), + ), + const SizedBox(height: 8), + text( + b.label, + 11, + TextType.Regular, + color: peak ? jp.text : jp.textFaint, + maxLines: 1, + overflow: TextOverflow.clip, + weight: peak ? FontWeight.w700 : FontWeight.w500, + ), + ], + ); + } + + Widget _toggle(JpColors jp) { + Widget seg(_Range r, String label) { + final active = _range == r; + return GestureDetector( + onTap: () => setState(() => _range = r), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: active ? jp.surface : Colors.transparent, + borderRadius: BorderRadius.circular(9), + boxShadow: active + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: text( + label, + 12.5, + TextType.Regular, + color: active ? jp.text : jp.textDim, + weight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + ); + } + + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: jp.surface2, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + seg(_Range.week, 'Week'), + seg(_Range.month, 'Month'), + seg(_Range.year, 'Year'), + ], + ), + ); + } +} diff --git a/lib/Peach/designs/Backdrop.dart b/lib/Peach/designs/Backdrop.dart new file mode 100644 index 0000000..58a7ef8 --- /dev/null +++ b/lib/Peach/designs/Backdrop.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../utils/Colors.dart'; + +/// App-wide ambient backdrop: a solid base with soft, blurred brand-tinted +/// glows sitting behind the (transparent) scaffolds. Deliberately restrained β€” +/// the warm vermilion anchors the brand, a cool counter-tone adds depth. +class Backdrop extends StatelessWidget { + final Widget child; + const Backdrop({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final jp = context.jp; + final dark = Theme.of(context).brightness == Brightness.dark; + return DecoratedBox( + decoration: BoxDecoration(color: jp.bg), + child: Stack( + children: [ + // Warm brand wash, upper-left. + Positioned( + top: -220, + left: -180, + child: _glow(PeachTheme.seed, dark ? 0.10 : 0.045, 560), + ), + // Cool counter-tone, lower-right, for depth. + Positioned( + bottom: -240, + right: -190, + child: _glow(PeachTheme.violet, dark ? 0.10 : 0.04, 560), + ), + // Faint high accent to lift the top of the canvas. + Positioned( + top: -320, + right: 40, + child: _glow(PeachTheme.accent, dark ? 0.05 : 0.02, 420), + ), + Positioned.fill(child: child), + ], + ), + ); + } + + Widget _glow(Color c, double opacity, double size) => IgnorePointer( + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + c.withValues(alpha: opacity), + c.withValues(alpha: 0), + ], + ), + ), + ), + ); +} diff --git a/lib/Peach/designs/InsightsCard.dart b/lib/Peach/designs/InsightsCard.dart new file mode 100644 index 0000000..e84c1aa --- /dev/null +++ b/lib/Peach/designs/InsightsCard.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import '../about/internal/application/TextType.dart'; + +import '../about/internal/application/ServerProfile.dart'; +import '../about/internal/application/DeploymentRecord.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// Warm gradient "insights" card that surfaces computed tips from the data. +class InsightsCard extends StatelessWidget { + final List servers; + final List history; + final VoidCallback onViewHistory; + + const InsightsCard({ + super.key, + required this.servers, + required this.history, + required this.onViewHistory, + }); + + List<(IconData, String)> _tips() { + final tips = <(IconData, String)>[]; + final failed = history + .where((h) => h.status == DeployStatus.failure) + .length; + final totalServices = servers.fold(0, (n, s) => n + s.services.length); + + if (history.isEmpty) { + tips.add(( + Icons.rocket_launch, + 'Add a service and run your first deploy to unlock insights.', + )); + } + if (failed > 0) { + tips.add(( + Icons.warning_amber, + '$failed failed ${failed == 1 ? 'deploy' : 'deploys'} β€” open the service logs to investigate.', + )); + } + // Busiest server + if (history.isNotEmpty) { + final counts = {}; + for (final h in history) { + counts[h.serverName] = (counts[h.serverName] ?? 0) + 1; + } + final busiest = counts.entries.reduce( + (a, b) => a.value >= b.value ? a : b, + ); + tips.add(( + Icons.trending_up, + '${busiest.key} is your busiest server (${busiest.value} deploys).', + )); + } + final emptyServers = servers.where((s) => s.services.isEmpty).length; + if (emptyServers > 0) { + tips.add(( + Icons.dns, + '$emptyServers ${emptyServers == 1 ? 'server has' : 'servers have'} no services yet β€” add one to start deploying.', + )); + } + if (totalServices > 0) { + tips.add(( + Icons.health_and_safety, + 'Open Health to check the live status of your $totalServices ${totalServices == 1 ? 'service' : 'services'}.', + )); + } + return tips.take(3).toList(); + } + + @override + Widget build(BuildContext context) { + const ink = PeachTheme.ink; + final tips = _tips(); + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: PeachTheme.peachGradient, + borderRadius: BorderRadius.circular(20), + boxShadow: PeachTheme.glow(PeachTheme.seed, opacity: 0.18, blur: 30), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.auto_awesome, size: 18, color: ink), + const SizedBox(width: 8), + text( + 'Insights', + 16, + TextType.Bold, + color: ink, + weight: FontWeight.w800, + ), + ], + ), + const SizedBox(height: 14), + for (final (icon, tip) in tips) ...[ + _tip(icon, tip), + const SizedBox(height: 8), + ], + const SizedBox(height: 4), + Align( + alignment: Alignment.centerRight, + child: FilledButton.icon( + onPressed: onViewHistory, + icon: const Icon(Icons.timeline, size: 16), + label: const Text('View history'), + style: FilledButton.styleFrom( + backgroundColor: ink, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _tip(IconData icon, String tip) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: PeachTheme.ink.withValues(alpha: 0.7)), + const SizedBox(width: 10), + Expanded( + child: text( + tip, + 12.5, + TextType.Regular, + color: PeachTheme.ink.withValues(alpha: 0.85), + height: 1.35, + weight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} diff --git a/lib/Peach/designs/LogConsole.dart b/lib/Peach/designs/LogConsole.dart new file mode 100644 index 0000000..77fc3ff --- /dev/null +++ b/lib/Peach/designs/LogConsole.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import '../about/internal/application/TextType.dart'; + +import '../comms/Comms.dart' show LogLine, LogLevel; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// Reusable terminal-style log console. It stays dark in both light and dark +/// app themes (like a code block), with colored, prefixed lines and auto-scroll. +class LogConsole extends StatefulWidget { + final List logs; + final double? height; + final bool live; + final VoidCallback? onClear; + final VoidCallback? onCopy; + + /// When the caller is showing a filtered subset, the size of the unfiltered + /// backlog β€” rendered as "shown / total" in the header. + final int? totalCount; + + /// Replaces the "Output will appear here." placeholder. + final String? emptyMessage; + + const LogConsole({ + super.key, + required this.logs, + this.height = 300, + this.live = false, + this.onClear, + this.onCopy, + this.totalCount, + this.emptyMessage, + }); + + @override + State createState() => _LogConsoleState(); +} + +class _LogConsoleState extends State { + final _scroll = ScrollController(); + int _last = 0; + + @override + void didUpdateWidget(LogConsole old) { + super.didUpdateWidget(old); + if (widget.logs.length != _last) { + _last = widget.logs.length; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scroll.hasClients) { + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ); + } + }); + } + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // A null height means the caller has already constrained us (e.g. inside + // an Expanded), so we fill whatever we're given. + return Container( + height: widget.height, + decoration: BoxDecoration( + color: context.jp.consoleBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: ConsoleColors.frame), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: ConsoleColors.frame)), + ), + child: Row( + children: [ + const Icon(Icons.terminal, size: 16, color: PeachTheme.seed), + const SizedBox(width: 8), + text( + 'LOGS', + 12, + TextType.Regular, + color: ConsoleColors.header, + letterSpacing: 1.5, + weight: FontWeight.w700, + ), + if (widget.live) ...[ + const SizedBox(width: 10), + const _LivePill(), + ], + const SizedBox(width: 10), + text( + widget.totalCount == null + ? '${widget.logs.length}' + : '${widget.logs.length} / ${widget.totalCount}', + 11, + TextType.Regular, + color: ConsoleColors.muted, + weight: FontWeight.w400, + ), + const Spacer(), + if (widget.onCopy != null) + IconButton( + tooltip: 'Copy', + iconSize: 16, + color: ConsoleColors.header, + onPressed: widget.logs.isEmpty ? null : widget.onCopy, + icon: const Icon(Icons.copy_all), + ), + if (widget.onClear != null) + _ClearButton( + enabled: widget.logs.isNotEmpty, + onTap: widget.onClear!, + ), + ], + ), + ), + Expanded( + child: widget.logs.isEmpty + ? Center( + child: text( + widget.emptyMessage ?? 'Output will appear here.', + 14, + TextType.Regular, + color: ConsoleColors.disabled, + weight: FontWeight.w400, + ), + ) + : ListView.builder( + controller: _scroll, + padding: const EdgeInsets.all(12), + itemCount: widget.logs.length, + itemBuilder: (_, i) => _row(widget.logs[i]), + ), + ), + ], + ), + ); + } + + Widget _row(LogLine l) { + final (color, prefix) = switch (l.level) { + LogLevel.error => (ConsoleColors.error, 'βœ– '), + LogLevel.warn => (ConsoleColors.warn, '! '), + LogLevel.success => (ConsoleColors.success, 'βœ“ '), + LogLevel.step => (ConsoleColors.step, '➀ '), + LogLevel.info => (ConsoleColors.info, 'Β· '), + LogLevel.remote => (ConsoleColors.body, ' '), + }; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: SelectableText( + '$prefix${l.text}', + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.35, + color: color, + ), + ), + ); + } +} + +/// Labeled "Clear" control: empties the on-screen backlog. New lines keep +/// streaming in afterwards. +class _ClearButton extends StatelessWidget { + final bool enabled; + final VoidCallback onTap; + const _ClearButton({required this.enabled, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Tooltip( + message: 'Clear the log backlog β€” new lines keep streaming', + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: enabled ? onTap : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: ConsoleColors.frameRaised), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete_sweep, + size: 14, + color: enabled ? ConsoleColors.header : ConsoleColors.disabled, + ), + const SizedBox(width: 5), + text( + 'Clear', + 11.5, + TextType.Regular, + color: enabled ? ConsoleColors.header : ConsoleColors.disabled, + weight: FontWeight.w700, + ), + ], + ), + ), + ), + ); + } +} + +class _LivePill extends StatelessWidget { + const _LivePill(); + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: ConsoleColors.live.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.fiber_manual_record, + size: 9, + color: ConsoleColors.live, + ), + const SizedBox(width: 4), + text( + 'LIVE', + 10, + TextType.Regular, + color: ConsoleColors.live, + weight: FontWeight.w700, + ), + ], + ), + ); + } +} diff --git a/lib/Peach/designs/MetricsPanel.dart b/lib/Peach/designs/MetricsPanel.dart new file mode 100644 index 0000000..29c4439 --- /dev/null +++ b/lib/Peach/designs/MetricsPanel.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import '../about/internal/application/TextType.dart'; + +import '../about/internal/application/DeploymentRecord.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; +import '../utils/CommonUtils.dart'; + +/// Deployment metrics: success-rate ring plus stat tiles. +class MetricsPanel extends StatelessWidget { + final List history; + const MetricsPanel({super.key, required this.history}); + + @override + Widget build(BuildContext context) { + final jp = context.jp; + final total = history.length; + final success = history + .where((h) => h.status == DeployStatus.success) + .length; + final failed = history + .where((h) => h.status == DeployStatus.failure) + .length; + final reverted = history + .where((h) => h.status == DeployStatus.reverted) + .length; + final attempts = success + failed; + final rate = attempts == 0 ? 0.0 : success / attempts; + final last = history.isNotEmpty ? history.first : null; + + return Container( + padding: const EdgeInsets.all(16), + decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + 'METRICS', + 12, + TextType.Regular, + color: jp.textDim, + letterSpacing: 1.2, + weight: FontWeight.w700, + ), + const SizedBox(height: 14), + Row( + children: [ + _rateRing(jp, rate, attempts), + const SizedBox(width: 18), + Expanded( + child: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _tile(jp, 'Total', '$total', jp.text), + _tile(jp, 'Success', '$success', PeachTheme.seed), + _tile(jp, 'Failed', '$failed', PeachTheme.danger), + _tile(jp, 'Reverted', '$reverted', PeachTheme.warn), + ], + ), + ), + ], + ), + if (last != null) ...[ + const SizedBox(height: 14), + Divider(height: 1, color: jp.border), + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.schedule, size: 14, color: jp.textFaint), + const SizedBox(width: 6), + Expanded( + child: text( + 'Last: ${last.serverName} Β· ${last.serviceLabel} Β· ${timeAgo(last.time)}', + 12.5, + TextType.Regular, + color: jp.textDim, + overflow: TextOverflow.ellipsis, + weight: FontWeight.w400, + ), + ), + _statusDot(last.status), + ], + ), + ], + ], + ), + ); + } + + Widget _rateRing(JpColors jp, double rate, int attempts) { + final pct = (rate * 100).round(); + final color = rate >= 0.8 + ? PeachTheme.seed + : rate >= 0.5 + ? PeachTheme.warn + : PeachTheme.danger; + return SizedBox( + width: 84, + height: 84, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 84, + height: 84, + child: CircularProgressIndicator( + value: attempts == 0 ? 0 : rate, + strokeWidth: 8, + backgroundColor: jp.surface2, + valueColor: AlwaysStoppedAnimation(color), + strokeCap: StrokeCap.round, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + text( + attempts == 0 ? 'β€”' : '$pct%', + 20, + TextType.Regular, + color: color, + weight: FontWeight.w800, + ), + text( + 'SUCCESS', + 8, + TextType.Regular, + color: jp.textFaint, + letterSpacing: 1, + weight: FontWeight.w700, + ), + ], + ), + ], + ), + ); + } + + Widget _tile(JpColors jp, String label, String value, Color color) { + return Container( + width: 92, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: jp.bg.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: jp.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + value, + 20, + TextType.Regular, + color: color, + weight: FontWeight.w800, + ), + text( + label.toUpperCase(), + 9, + TextType.Regular, + color: jp.textFaint, + letterSpacing: 0.8, + weight: FontWeight.w700, + ), + ], + ), + ); + } + + Widget _statusDot(DeployStatus s) { + final color = switch (s) { + DeployStatus.success => PeachTheme.seed, + DeployStatus.failure => PeachTheme.danger, + DeployStatus.reverted => PeachTheme.warn, + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + ), + child: text( + s.label, + 10, + TextType.Regular, + color: color, + weight: FontWeight.w700, + ), + ); + } +} diff --git a/lib/Peach/designs/OutputSheet.dart b/lib/Peach/designs/OutputSheet.dart new file mode 100644 index 0000000..ebedbf3 --- /dev/null +++ b/lib/Peach/designs/OutputSheet.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// A scrollable, copyable console sheet for one-shot command output β€” docker +/// logs, a kafka describe, a redis value. Raised from a screen rather than +/// pushed, because the output is a peek, not a destination. +void showOutputSheet(BuildContext context, String title, String output) { + final jp = context.jp; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: jp.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => SizedBox( + height: MediaQuery.of(sheetContext).size.height * 0.7, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: text(title, 16, TextType.Bold, color: jp.text)), + IconButton( + tooltip: 'Copy', + onPressed: () { + Clipboard.setData(ClipboardData(text: output)); + ScaffoldMessenger.of( + sheetContext, + ).showSnackBar(const SnackBar(content: Text('Copied.'))); + }, + icon: const Icon(Icons.copy_all, size: 18), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: jp.consoleBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: ConsoleColors.frame), + ), + child: SingleChildScrollView( + child: SelectableText( + output, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.4, + color: ConsoleColors.body, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); +} diff --git a/lib/Peach/designs/RecentDeploys.dart b/lib/Peach/designs/RecentDeploys.dart new file mode 100644 index 0000000..c961b7d --- /dev/null +++ b/lib/Peach/designs/RecentDeploys.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import '../about/internal/application/TextType.dart'; + +import '../about/internal/application/DeploymentRecord.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; +import '../utils/CommonUtils.dart'; + +/// A compact "recently deployed" list of the latest deployments. +class RecentDeploys extends StatelessWidget { + final List history; + final VoidCallback onViewAll; + final int max; + + const RecentDeploys({ + super.key, + required this.history, + required this.onViewAll, + this.max = 5, + }); + + @override + Widget build(BuildContext context) { + final jp = context.jp; + final items = history.take(max).toList(); + return Container( + decoration: PeachTheme.panel(context), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 8), + child: Row( + children: [ + const Icon(Icons.bolt, size: 16, color: PeachTheme.seed), + const SizedBox(width: 6), + text( + 'RECENTLY DEPLOYED', + 12, + TextType.Regular, + color: jp.textDim, + letterSpacing: 1.2, + weight: FontWeight.w700, + ), + const Spacer(), + TextButton(onPressed: onViewAll, child: const Text('View all')), + ], + ), + ), + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) + Divider(height: 1, indent: 16, endIndent: 16, color: jp.border), + _row(jp, items[i]), + ], + ], + ), + ); + } + + Widget _row(JpColors jp, DeploymentRecord r) { + final (color, icon) = switch (r.status) { + DeployStatus.success => (PeachTheme.seed, Icons.check_circle), + DeployStatus.failure => (PeachTheme.danger, Icons.error), + DeployStatus.reverted => (PeachTheme.warn, Icons.undo), + }; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + '${r.serverName} Β· ${r.serviceLabel}', + 13.5, + TextType.Regular, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + weight: FontWeight.w600, + ), + const SizedBox(height: 1), + Text( + r.jarName.isEmpty ? r.remotePath : r.jarName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5, + fontFamily: 'monospace', + color: jp.textFaint, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + text( + timeAgo(r.time), + 11.5, + TextType.Regular, + color: jp.textFaint, + weight: FontWeight.w400, + ), + ], + ), + ); + } +} diff --git a/lib/Peach/designs/Responsive.dart b/lib/Peach/designs/Responsive.dart new file mode 100644 index 0000000..ae03ff7 --- /dev/null +++ b/lib/Peach/designs/Responsive.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import 'text/Text.dart'; + +/// Picks one of three layouts for the current width. Screens are built as +/// `_mobileView` / `_tabletView` / `_desktopView` methods on their State and +/// handed to this widget, rather than as one layout littered with width checks. +class Responsive extends StatelessWidget { + final Widget mobile; + final Widget tablet; + final Widget desktop; + + const Responsive({ + super.key, + required this.desktop, + required this.mobile, + required this.tablet, + }); + + /// mobile < 650 + static bool isMobile(BuildContext context) => + MediaQuery.sizeOf(context).width < 650; + + /// tablet >= 650 + static bool isTablet(BuildContext context) => + MediaQuery.sizeOf(context).width >= 650; + + /// desktop >= 1100 + static bool isDesktop(BuildContext context) => + MediaQuery.sizeOf(context).width >= 1100; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (isDesktop(context)) { + return desktop; + } else if (isTablet(context)) { + return tablet; + } else if (isMobile(context)) { + return mobile; + } else { + return SizedBox( + width: double.infinity, + height: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [text("NOT SUPPORTED", 12, TextType.Bold)], + ), + ); + } + }, + ); + } +} diff --git a/lib/Peach/designs/StatCard.dart b/lib/Peach/designs/StatCard.dart new file mode 100644 index 0000000..13f56f6 --- /dev/null +++ b/lib/Peach/designs/StatCard.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// A horizontal metric card: icon chip on the left, big value + label on the +/// right, with an optional trailing accent (e.g. a mini ring or delta). +class StatCard extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + final String? sub; + final Widget? trailing; + + const StatCard({ + super.key, + required this.icon, + required this.label, + required this.value, + required this.color, + this.sub, + this.trailing, + }); + + @override + Widget build(BuildContext context) { + final jp = context.jp; + return Container( + padding: const EdgeInsets.all(16), + decoration: PeachTheme.panel(context, glowColor: color, radius: 18), + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color.withValues(alpha: 0.32), + color.withValues(alpha: 0.14), + ], + ), + borderRadius: BorderRadius.circular(13), + border: Border.all(color: color.withValues(alpha: 0.40)), + boxShadow: PeachTheme.glow(color, opacity: 0.22, blur: 16), + ), + child: Icon(icon, color: color, size: 22), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + text( + value, + 25, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + letterSpacing: -0.6, + height: 1.05, + weight: FontWeight.w800, + ), + const SizedBox(height: 2), + text( + label.toUpperCase(), + 10.5, + TextType.Regular, + color: jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + letterSpacing: 0.8, + weight: FontWeight.w700, + ), + if (sub != null) + text( + sub!, + 11, + TextType.Regular, + weight: FontWeight.w400, + color: jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (trailing != null) ...[const SizedBox(width: 8), trailing!], + ], + ), + ); + } +} diff --git a/lib/Peach/designs/StatsDonut.dart b/lib/Peach/designs/StatsDonut.dart new file mode 100644 index 0000000..d2dc4df --- /dev/null +++ b/lib/Peach/designs/StatsDonut.dart @@ -0,0 +1,184 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import '../about/internal/application/TextType.dart'; + +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// Job-statistics style card: a donut gauge with the total in the centre, plus +/// labelled progress bars underneath. +class StatsDonut extends StatelessWidget { + final int success; + final int failed; + final int reverted; + const StatsDonut({ + super.key, + required this.success, + required this.failed, + required this.reverted, + }); + + int get _total => success + failed + reverted; + + @override + Widget build(BuildContext context) { + final jp = context.jp; + final total = _total; + double pct(int n) => total == 0 ? 0 : n / total; + return Container( + padding: const EdgeInsets.all(18), + decoration: PeachTheme.panel(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + text( + 'Deploy statistics', + 16, + TextType.Bold, + color: jp.text, + weight: FontWeight.w800, + ), + const Spacer(), + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: jp.surface2, + borderRadius: BorderRadius.circular(9), + ), + child: Icon(Icons.open_in_full, size: 14, color: jp.textDim), + ), + ], + ), + const SizedBox(height: 14), + Center( + child: SizedBox( + width: 176, + height: 176, + child: CustomPaint( + painter: _DonutPainter( + track: jp.surface2, + segments: [ + (pct(success), PeachTheme.seed), + (pct(failed), PeachTheme.danger), + (pct(reverted), PeachTheme.slate), + ], + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + text( + '$total', + 38, + TextType.Bold, + color: jp.text, + height: 1, + weight: FontWeight.w800, + ), + const SizedBox(height: 2), + text( + 'Total deploys', + 12, + TextType.Regular, + color: jp.textFaint, + weight: FontWeight.w400, + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 18), + _bar(jp, 'Successful', success, pct(success), PeachTheme.seed), + const SizedBox(height: 14), + _bar(jp, 'Failed', failed, pct(failed), PeachTheme.danger), + const SizedBox(height: 14), + _bar(jp, 'Reverted', reverted, pct(reverted), PeachTheme.slate), + ], + ), + ); + } + + Widget _bar(JpColors jp, String label, int n, double frac, Color color) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + text( + label, + 13, + TextType.Regular, + color: jp.textDim, + weight: FontWeight.w600, + ), + const Spacer(), + text( + '${(frac * 100).round()}%', + 13, + TextType.Regular, + color: jp.text, + weight: FontWeight.w800, + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Stack( + children: [ + Container(height: 8, color: jp.surface2), + FractionallySizedBox( + widthFactor: frac.clamp(0.0, 1.0), + child: Container(height: 8, color: color), + ), + ], + ), + ), + ], + ); + } +} + +class _DonutPainter extends CustomPainter { + final List<(double, Color)> segments; + final Color track; + _DonutPainter({required this.segments, required this.track}); + + @override + void paint(Canvas canvas, Size size) { + const stroke = 18.0; + final rect = + Offset(stroke / 2, stroke / 2) & + Size(size.width - stroke, size.height - stroke); + final trackPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = track; + canvas.drawArc(rect, 0, 2 * math.pi, false, trackPaint); + + const gap = 0.06; + var start = -math.pi / 2; + for (final (frac, color) in segments) { + if (frac <= 0) continue; + final full = frac * 2 * math.pi; + final sweep = (full - gap).clamp(0.02, 2 * math.pi); + final p = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..color = color; + canvas.drawArc(rect, start + gap / 2, sweep, false, p); + start += full; + } + } + + @override + bool shouldRepaint(_DonutPainter old) => + old.segments != segments || old.track != track; +} diff --git a/lib/Peach/designs/StepList.dart b/lib/Peach/designs/StepList.dart new file mode 100644 index 0000000..649102f --- /dev/null +++ b/lib/Peach/designs/StepList.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart' hide StepState; + +import '../about/internal/application/StepTracker.dart'; +import '../about/internal/application/TextType.dart'; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// Renders an ordered list of deploy/build steps with live state and, for the +/// active step, an inline progress bar. +class StepList extends StatelessWidget { + final List steps; + const StepList({super.key, required this.steps}); + + @override + Widget build(BuildContext context) { + final jp = context.jp; + final doneCount = steps.where((s) => s.state == StepState.done).length; + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + text( + 'PROGRESS', + 12, + TextType.Regular, + color: jp.textDim, + letterSpacing: 1.2, + weight: FontWeight.w700, + ), + const Spacer(), + text( + '$doneCount / ${steps.length}', + 12, + TextType.Regular, + color: PeachTheme.seed, + weight: FontWeight.w700, + ), + ], + ), + const SizedBox(height: 10), + for (var i = 0; i < steps.length; i++) + _StepRow(step: steps[i], isLast: i == steps.length - 1, jp: jp), + ], + ), + ); + } +} + +class _StepRow extends StatelessWidget { + final DeployStep step; + final bool isLast; + final JpColors jp; + const _StepRow({required this.step, required this.isLast, required this.jp}); + + @override + Widget build(BuildContext context) { + final active = step.state == StepState.active; + final (color, icon) = switch (step.state) { + StepState.done => (PeachTheme.seed, Icons.check), + StepState.active => (PeachTheme.seed, Icons.autorenew), + StepState.failed => (PeachTheme.danger, Icons.close), + StepState.skipped => (jp.textFaint, Icons.remove), + StepState.pending => (jp.textFaint, Icons.circle_outlined), + }; + final pct = step.progress == null + ? null + : (step.progress! * 100).clamp(0, 100).toStringAsFixed(0); + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + _dot(color, icon, active), + if (!isLast) + Expanded( + child: Container( + width: 2, + margin: const EdgeInsets.symmetric(vertical: 2), + color: step.state == StepState.done + ? PeachTheme.seed.withValues(alpha: 0.4) + : jp.border, + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 14, top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: text( + step.label, + 13.5, + TextType.Regular, + color: step.state == StepState.pending + ? jp.textFaint + : jp.text, + weight: active ? FontWeight.w700 : FontWeight.w500, + ), + ), + if (pct != null && active) + text( + '$pct%', + 12, + TextType.Regular, + color: PeachTheme.seed, + weight: FontWeight.w700, + ), + ], + ), + if (active && step.progress != null) ...[ + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: step.progress, + minHeight: 5, + backgroundColor: jp.surface2, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + Widget _dot(Color color, IconData icon, bool active) { + return Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: color.withValues(alpha: active ? 0.18 : 0.12), + shape: BoxShape.circle, + border: Border.all(color: color.withValues(alpha: 0.6)), + ), + child: active + ? const Padding( + padding: EdgeInsets.all(5), + child: CircularProgressIndicator( + strokeWidth: 2, + color: PeachTheme.seed, + ), + ) + : Icon(icon, size: 15, color: color), + ); + } +} diff --git a/lib/Peach/designs/TransferCard.dart b/lib/Peach/designs/TransferCard.dart new file mode 100644 index 0000000..4ac3511 --- /dev/null +++ b/lib/Peach/designs/TransferCard.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import '../about/internal/application/TextType.dart'; + +import '../comms/Comms.dart' show TransferProgress; +import '../utils/Colors.dart'; +import 'text/Text.dart'; + +/// A descriptive, non-flat transfer panel: big percentage, a gradient progress +/// bar, and live stats (transferred, speed, ETA). +class TransferCard extends StatelessWidget { + final TransferProgress progress; + const TransferCard({super.key, required this.progress}); + + @override + Widget build(BuildContext context) { + final jp = context.jp; + final pct = progress.fraction == null + ? null + : (progress.fraction! * 100).clamp(0, 100); + final verb = progress.uploading ? 'Uploading' : 'Downloading'; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: jp.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: PeachTheme.seed.withValues(alpha: 0.5)), + boxShadow: [ + ...PeachTheme.softShadow(Theme.of(context).brightness), + ...PeachTheme.glow(PeachTheme.seed, opacity: 0.18, blur: 30), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Icon( + progress.uploading ? Icons.cloud_upload : Icons.cloud_download, + color: PeachTheme.seed, + size: 22, + ), + const SizedBox(width: 8), + text( + verb, + 15, + TextType.Regular, + color: jp.text, + weight: FontWeight.w700, + ), + const Spacer(), + text( + pct == null ? 'β€”' : '${pct.toStringAsFixed(0)}%', + 26, + TextType.Regular, + color: PeachTheme.seed, + height: 1, + weight: FontWeight.w800, + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Stack( + children: [ + Container(height: 10, color: jp.surface2), + FractionallySizedBox( + widthFactor: (progress.fraction ?? 0).clamp(0.0, 1.0), + child: Container( + height: 10, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [PeachTheme.seed, PeachTheme.accent], + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + Row( + children: [ + _stat( + jp, + Icons.data_usage, + 'Transferred', + '${_bytes(progress.sent)} / ${_bytes(progress.total)}', + ), + _sep(jp), + _stat( + jp, + Icons.speed, + 'Speed', + '${_bytes(progress.bytesPerSec.round())}/s', + ), + _sep(jp), + _stat(jp, Icons.timer_outlined, 'ETA', _eta(progress.eta)), + ], + ), + ], + ), + ); + } + + Widget _sep(JpColors jp) => Container(width: 1, height: 30, color: jp.border); + + Widget _stat(JpColors jp, IconData icon, String label, String value) { + return Expanded( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 12, color: jp.textFaint), + const SizedBox(width: 4), + text( + label.toUpperCase(), + 9, + TextType.Regular, + color: jp.textFaint, + letterSpacing: 0.8, + weight: FontWeight.w700, + ), + ], + ), + const SizedBox(height: 3), + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5, + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + color: jp.text, + ), + ), + ], + ), + ); + } + + static String _bytes(int b) { + if (b < 1024) return '$b B'; + if (b < 1024 * 1024) return '${(b / 1024).toStringAsFixed(0)} KB'; + if (b < 1024 * 1024 * 1024) { + return '${(b / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(b / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; + } + + static String _eta(Duration? d) { + if (d == null) return 'β€”'; + final s = d.inSeconds; + if (s <= 0) return '0s'; + if (s < 60) return '${s}s'; + final m = s ~/ 60; + final rem = s % 60; + if (m < 60) return '${m}m ${rem}s'; + final h = m ~/ 60; + return '${h}h ${m % 60}m'; + } +} diff --git a/lib/Peach/designs/input/InputFields.dart b/lib/Peach/designs/input/InputFields.dart new file mode 100644 index 0000000..6e3921a --- /dev/null +++ b/lib/Peach/designs/input/InputFields.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +import '../../about/internal/application/TextType.dart'; +import '../../utils/Colors.dart'; +import '../text/Text.dart'; + +/// The standard labelled text field. Every form in the app uses this rather +/// than building a bare [TextFormField], so labels, spacing and validation +/// messages stay identical everywhere. +Widget inputField({ + required String label, + required String initial, + required void Function(String) onSaved, + String? hint, + bool required = false, + bool obscure = false, + TextInputType? keyboardType, + Widget? suffix, + String? Function(String?)? validator, + void Function(String)? onChanged, + TextEditingController? controller, + int? maxLines = 1, + bool enabled = true, +}) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextFormField( + initialValue: controller == null ? initial : null, + controller: controller, + obscureText: obscure, + keyboardType: keyboardType, + maxLines: obscure ? 1 : maxLines, + enabled: enabled, + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + hintText: hint, + suffixIcon: suffix, + ), + validator: + validator ?? + (required + ? (v) => (v == null || v.trim().isEmpty) ? 'Required' : null + : null), + onSaved: (v) => onSaved(v ?? ''), + ), + ); +} + +/// A section heading inside a form. +Widget inputSection(BuildContext context, String title) => Padding( + padding: const EdgeInsets.only(top: 20, bottom: 10), + child: text( + title.toUpperCase(), + 12, + TextType.Bold, + color: context.jp.textDim, + letterSpacing: 1.2, + ), +); + +/// A one-line explanatory note under a field. +Widget inputHint(BuildContext context, IconData icon, String message) => + Padding( + padding: const EdgeInsets.only(left: 4, top: 2, bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 13, color: context.jp.textFaint), + const SizedBox(width: 6), + Expanded( + child: text( + message, + 12, + TextType.Regular, + color: context.jp.textFaint, + ), + ), + ], + ), + ); + +/// A labelled switch row, matching the field spacing. +Widget inputSwitch({ + required BuildContext context, + required String title, + required String subtitle, + required bool value, + required void Function(bool) onChanged, +}) => SwitchListTile( + contentPadding: EdgeInsets.zero, + value: value, + onChanged: onChanged, + title: text(title, 14, TextType.Regular, color: context.jp.text), + subtitle: text(subtitle, 12, TextType.Light, color: context.jp.textDim), +); diff --git a/lib/Peach/designs/navigation/SideNav.dart b/lib/Peach/designs/navigation/SideNav.dart new file mode 100644 index 0000000..e4fe4fd --- /dev/null +++ b/lib/Peach/designs/navigation/SideNav.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import '../../about/internal/application/TextType.dart'; + +import '../../utils/Colors.dart'; +import '../text/Text.dart'; + +class NavItemData { + final IconData icon; + final String label; + final int? badge; + final VoidCallback onTap; + final bool selected; + const NavItemData({ + required this.icon, + required this.label, + required this.onTap, + this.badge, + this.selected = false, + }); +} + +/// Left navigation rail / sidebar. [expanded] shows labels; otherwise a +/// compact icon rail. +class SideNav extends StatelessWidget { + final bool expanded; + final List items; + final ThemeMode themeMode; + final Future Function() onCycleTheme; + final VoidCallback onNewServer; + + const SideNav({ + super.key, + required this.expanded, + required this.items, + required this.themeMode, + required this.onCycleTheme, + required this.onNewServer, + }); + + (IconData, String) get _themeIcon => switch (themeMode) { + ThemeMode.dark => (Icons.dark_mode, 'Dark'), + ThemeMode.light => (Icons.light_mode, 'Light'), + ThemeMode.system => (Icons.brightness_auto, 'System'), + }; + + @override + Widget build(BuildContext context) { + final jp = context.jp; + final (themeI, themeLabel) = _themeIcon; + return Container( + width: expanded ? 236 : 78, + decoration: BoxDecoration( + color: jp.surface, + border: Border(right: BorderSide(color: jp.border)), + ), + child: SafeArea( + right: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(expanded ? 20 : 0, 20, 0, 20), + child: Row( + mainAxisAlignment: expanded + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + gradient: PeachTheme.brandGradient, + borderRadius: BorderRadius.circular(11), + boxShadow: PeachTheme.glow( + PeachTheme.seed, + opacity: 0.5, + blur: 18, + ), + ), + child: const Icon( + Icons.rocket_launch, + color: Colors.white, + size: 20, + ), + ), + if (expanded) ...[ + const SizedBox(width: 12), + text( + 'Peach', + 20, + TextType.Bold, + color: jp.text, + weight: FontWeight.w800, + ), + ], + ], + ), + ), + Expanded( + child: ListView( + padding: EdgeInsets.symmetric(horizontal: expanded ? 12 : 10), + children: [for (final it in items) _navItem(context, it)], + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: expanded ? 12 : 10), + child: Column( + children: [ + _footerButton( + context, + themeI, + themeLabel, + (_) => onCycleTheme(), + ), + const SizedBox(height: 6), + _newServerButton(context), + const SizedBox(height: 14), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _navItem(BuildContext context, NavItemData it) { + final jp = context.jp; + final inner = Container( + padding: EdgeInsets.symmetric( + horizontal: expanded ? 14 : 0, + vertical: 12, + ), + decoration: BoxDecoration( + color: it.selected + ? PeachTheme.seed.withValues(alpha: 0.13) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: expanded + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + Icon( + it.icon, + size: 20, + color: it.selected ? PeachTheme.seed : jp.textDim, + ), + if (expanded) ...[ + const SizedBox(width: 12), + Expanded( + child: text( + it.label, + 14, + TextType.Regular, + color: it.selected ? jp.text : jp.textDim, + overflow: TextOverflow.ellipsis, + weight: it.selected ? FontWeight.w700 : FontWeight.w600, + ), + ), + if (it.badge != null && it.badge! > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1), + decoration: BoxDecoration( + color: jp.surface2, + borderRadius: BorderRadius.circular(20), + ), + child: text( + '${it.badge}', + 11, + TextType.Regular, + color: jp.textDim, + weight: FontWeight.w700, + ), + ), + ], + ], + ), + ); + // A premium left accent bar marks the selected item. + final content = Container( + margin: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 3, + height: it.selected ? 22 : 0, + margin: EdgeInsets.only(right: expanded ? 8 : 0), + decoration: BoxDecoration( + color: PeachTheme.seed, + borderRadius: BorderRadius.circular(3), + ), + ), + Expanded(child: inner), + ], + ), + ); + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: it.onTap, + child: expanded ? content : Tooltip(message: it.label, child: content), + ); + } + + Widget _footerButton( + BuildContext context, + IconData icon, + String label, + void Function(BuildContext) onTap, + ) { + final jp = context.jp; + final w = InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => onTap(context), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: expanded ? 14 : 0, + vertical: 12, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: jp.border), + ), + child: Row( + mainAxisAlignment: expanded + ? MainAxisAlignment.start + : MainAxisAlignment.center, + children: [ + Icon(icon, size: 19, color: jp.textDim), + if (expanded) ...[ + const SizedBox(width: 12), + text( + 'Theme: $label', + 13, + TextType.Regular, + color: jp.textDim, + weight: FontWeight.w400, + ), + ], + ], + ), + ), + ); + return expanded ? w : Tooltip(message: 'Theme: $label', child: w); + } + + Widget _newServerButton(BuildContext context) { + if (!expanded) { + return Tooltip( + message: 'New server', + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onNewServer, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + gradient: PeachTheme.brandGradient, + borderRadius: BorderRadius.circular(12), + boxShadow: PeachTheme.glow( + PeachTheme.seed, + opacity: 0.5, + blur: 16, + ), + ), + child: const Icon(Icons.add, color: Colors.white), + ), + ), + ); + } + return SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: onNewServer, + icon: const Icon(Icons.add), + label: const Text('New server'), + ), + ); + } +} diff --git a/lib/Peach/designs/text/Text.dart b/lib/Peach/designs/text/Text.dart new file mode 100644 index 0000000..39ea6df --- /dev/null +++ b/lib/Peach/designs/text/Text.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../../about/internal/application/TextType.dart'; +import '../../utils/Colors.dart'; + +/// Resolves a [TextType] role to the font family that carries it. Display copy +/// (Bold) uses Sora; body and secondary copy use Urbanist and PlusJakartaSans. +String getTextType(TextType type) { + switch (type) { + case TextType.Bold: + return 'Sora'; + case TextType.Regular: + return 'Urbanist'; + case TextType.Light: + return 'PlusJakartaSans'; + } +} + +/// The only way display copy is drawn. Never build a raw `Text`/`TextStyle` +/// for user-facing strings β€” go through here so family, weight and colour stay +/// consistent across every screen. +/// +/// [color] defaults to the theme's primary text colour when a [BuildContext] +/// is supplied, so a call site rarely needs to name a colour at all. +Widget text( + String text, + double textSize, + TextType type, { + BuildContext? context, + Color? color, + TextAlign? align, + int? maxLines, + TextOverflow? overflow, + double? letterSpacing, + double? height, + double? wordSpacing, + TextDecoration? decoration, + FontWeight? weight, +}) { + return Text( + text, + textAlign: align ?? TextAlign.left, + maxLines: maxLines, + overflow: overflow, + style: TextStyle( + decoration: decoration ?? TextDecoration.none, + color: color ?? (context != null ? context.jp.text : colorPrimaryDark), + fontSize: textSize, + fontFamily: getTextType(type), + letterSpacing: letterSpacing, + height: height, + wordSpacing: wordSpacing, + fontWeight: weight ?? _weightFor(type), + ), + ); +} + +FontWeight _weightFor(TextType type) { + switch (type) { + case TextType.Bold: + return FontWeight.w700; + case TextType.Regular: + return FontWeight.w500; + case TextType.Light: + return FontWeight.w400; + } +} diff --git a/lib/Peach/informatics/AppDataManager.dart b/lib/Peach/informatics/AppDataManager.dart new file mode 100644 index 0000000..05cbe24 --- /dev/null +++ b/lib/Peach/informatics/AppDataManager.dart @@ -0,0 +1,440 @@ +import 'dart:typed_data'; + +import '../about/external/data/Docker.dart'; +import '../about/external/data/Health.dart'; +import '../about/external/data/Kafka.dart'; +import '../about/external/data/Redis.dart'; +import '../about/internal/application/DeploymentRecord.dart'; +import '../about/internal/application/ServerProfile.dart'; +import '../about/internal/application/ServiceConfig.dart'; +import '../about/internal/application/StepTracker.dart'; +import '../about/internal/file/BuildService.dart'; +import '../about/internal/file/ConnectFileStorage.dart'; +import '../comms/Comms.dart'; +import '../comms/ConnectComms.dart'; +import '../memory/ConnectInternalMemory.dart'; +import 'DataManager.dart'; + +/// The single concrete [DataManager], constructed once in `ParentViewModel` and +/// shared by every screen. It holds no logic β€” it only forwards each call to +/// whichever of the three collaborators owns it. +class AppDataManager implements DataManager { + final ConnectInternalMemory memory; + final ConnectComms comms; + final ConnectFileStorage files; + + AppDataManager(this.memory, this.comms, this.files); + + // ── memory ──────────────────────────────────────────────────────────────── + + @override + Future> getServers() => memory.getServers(); + + @override + Future setServers(List servers) => memory.setServers(servers); + + @override + Future deleteServerSecret(String serverId) => + memory.deleteServerSecret(serverId); + + @override + Future> getDeployments() => memory.getDeployments(); + + @override + Future> addDeployment(DeploymentRecord record) => + memory.addDeployment(record); + + @override + Future clearDeployments() => memory.clearDeployments(); + + @override + Future getThemeMode() => memory.getThemeMode(); + + @override + Future setThemeMode(String mode) => memory.setThemeMode(mode); + + // ── comms: session ──────────────────────────────────────────────────────── + + @override + bool get isConnected => comms.isConnected; + + @override + bool get canRevert => comms.canRevert; + + @override + Stream get logs => comms.logs; + + @override + Stream get transferProgress => comms.transferProgress; + + @override + Stream> get stepStream => comms.stepStream; + + @override + Future connect(ServerProfile s) => comms.connect(s); + + @override + void requestCancel() => comms.requestCancel(); + + @override + void disconnect() => comms.disconnect(); + + @override + void dispose() => comms.dispose(); + + // ── comms: deployment ───────────────────────────────────────────────────── + + @override + Future deploy({ + required ServerProfile server, + required ServiceConfig service, + required Uint8List jarBytes, + required String jarLabel, + }) => comms.deploy( + server: server, + service: service, + jarBytes: jarBytes, + jarLabel: jarLabel, + ); + + @override + Future deployWeb({ + required ServerProfile server, + required ServiceConfig service, + required Uint8List archiveBytes, + required int fileCount, + }) => comms.deployWeb( + server: server, + service: service, + archiveBytes: archiveBytes, + fileCount: fileCount, + ); + + @override + Future restartOnly( + ServerProfile server, + ServiceConfig service, + ) => comms.restartOnly(server, service); + + @override + Future revert() => comms.revert(); + + @override + Future discardBackup() => comms.discardBackup(); + + // ── comms: provisioning ─────────────────────────────────────────────────── + + @override + Future installSystemdUnit({ + required ServerProfile server, + required String unitName, + required String unitContent, + bool enable = true, + bool startNow = true, + }) => comms.installSystemdUnit( + server: server, + unitName: unitName, + unitContent: unitContent, + enable: enable, + startNow: startNow, + ); + + @override + Future detectPackageManager(ServerProfile server) => + comms.detectPackageManager(server); + + @override + Future> probeEnvironment( + ServerProfile server, + List detectByKey, + ) => comms.probeEnvironment(server, detectByKey); + + @override + Future installEnvironment({ + required ServerProfile server, + required String command, + required List units, + }) => + comms.installEnvironment(server: server, command: command, units: units); + + @override + Future installPackages({ + required ServerProfile server, + required String command, + String? enableUnit, + }) => comms.installPackages( + server: server, + command: command, + enableUnit: enableUnit, + ); + + // ── comms: service & health ─────────────────────────────────────────────── + + @override + Future checkHealth( + ServerProfile server, + ServiceConfig service, + ) => comms.checkHealth(server, service); + + @override + Future serviceStatus(ServerProfile server, ServiceConfig service) => + comms.serviceStatus(server, service); + + @override + Future serviceControl( + ServerProfile server, + ServiceConfig service, + String action, + ) => comms.serviceControl(server, service, action); + + @override + Future> nginxRoots(ServerProfile server) => + comms.nginxRoots(server); + + // ── comms: logs ─────────────────────────────────────────────────────────── + + @override + Future fetchLogs( + ServerProfile server, + ServiceConfig service, { + DateTime? since, + DateTime? until, + }) => comms.fetchLogs(server, service, since: since, until: until); + + @override + Future followLogs(ServerProfile server, ServiceConfig service) => + comms.followLogs(server, service); + + @override + void stopLogs() => comms.stopLogs(); + + // ── comms: redis ────────────────────────────────────────────────────────── + + @override + Future<(bool, String)> redisPing( + ServerProfile server, { + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) => comms.redisPing(server, host: host, port: port, pass: pass, db: db); + + @override + Future redisConfigPassword(ServerProfile server) => + comms.redisConfigPassword(server); + + @override + Future redisDbSize( + ServerProfile server, { + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) => comms.redisDbSize(server, host: host, port: port, pass: pass, db: db); + + @override + Future<(int, Map)> redisDatabases( + ServerProfile server, { + String host = '127.0.0.1', + int port = 6379, + String pass = '', + }) => comms.redisDatabases(server, host: host, port: port, pass: pass); + + @override + Future> redisKeysTyped( + ServerProfile server, { + String pattern = '*', + String filterType = '', + int limit = 50000, + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) => comms.redisKeysTyped( + server, + pattern: pattern, + filterType: filterType, + limit: limit, + host: host, + port: port, + pass: pass, + db: db, + ); + + @override + Future> redisKeys( + ServerProfile server, { + String pattern = '*', + int limit = 100000, + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) => comms.redisKeys( + server, + pattern: pattern, + limit: limit, + host: host, + port: port, + pass: pass, + db: db, + ); + + @override + Future redisGet( + ServerProfile server, + String key, { + String host = '127.0.0.1', + int port = 6379, + String pass = '', + int db = 0, + }) => comms.redisGet(server, key, host: host, port: port, pass: pass, db: db); + + // ── comms: docker ───────────────────────────────────────────────────────── + + @override + Future dockerStatus(ServerProfile server) => + comms.dockerStatus(server); + + @override + Future> dockerPs( + ServerProfile server, { + bool all = true, + }) => comms.dockerPs(server, all: all); + + @override + Future dockerAction(ServerProfile server, String id, String action) => + comms.dockerAction(server, id, action); + + @override + Future dockerLogs( + ServerProfile server, + String id, { + int tail = 300, + }) => comms.dockerLogs(server, id, tail: tail); + + @override + Future dockerTest(ServerProfile server) => comms.dockerTest(server); + + @override + Future<(bool, String)> dockerRun(ServerProfile server, DockerRunSpec spec) => + comms.dockerRun(server, spec); + + // ── comms: kafka ────────────────────────────────────────────────────────── + + @override + Future kafkaDetect(ServerProfile server) => + comms.kafkaDetect(server); + + @override + Future kafkaTest( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }) => comms.kafkaTest( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + ); + + @override + Future> kafkaTopics( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + bool includeInternal = false, + }) => comms.kafkaTopics( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + includeInternal: includeInternal, + ); + + @override + Future> kafkaGroups( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }) => comms.kafkaGroups( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + ); + + @override + Future kafkaCluster( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }) => comms.kafkaCluster( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + ); + + // ── comms: remote filesystem ────────────────────────────────────────────── + + @override + Future> listDir(ServerProfile server, String path) => + comms.listDir(server, path); + + @override + Future resolveHome(ServerProfile server) => comms.resolveHome(server); + + @override + Future uploadFile( + ServerProfile server, + Uint8List bytes, + String remotePath, + ) => comms.uploadFile(server, bytes, remotePath); + + @override + Future downloadFile(ServerProfile server, String remotePath) => + comms.downloadFile(server, remotePath); + + @override + Future deleteRemote( + ServerProfile server, + String remotePath, + bool isDir, + ) => comms.deleteRemote(server, remotePath, isDir); + + // ── files ───────────────────────────────────────────────────────────────── + + @override + bool get buildSupported => files.buildSupported; + + @override + Stream get buildLogs => files.buildLogs; + + @override + Stream> get buildStepStream => files.buildStepStream; + + @override + Future build(ServiceConfig service) => files.build(service); + + @override + Future buildWeb(ServiceConfig service) => + files.buildWeb(service); + + @override + void cancelBuild() => files.cancelBuild(); + + @override + Future readArtifact(String path) => files.readArtifact(path); + + @override + Future artifactExists(String path) => files.artifactExists(path); + + @override + Future<(Uint8List, int)> packageWeb(String directoryPath) => + files.packageWeb(directoryPath); +} diff --git a/lib/Peach/informatics/DataManager.dart b/lib/Peach/informatics/DataManager.dart new file mode 100644 index 0000000..65d0503 --- /dev/null +++ b/lib/Peach/informatics/DataManager.dart @@ -0,0 +1,10 @@ +import '../about/internal/file/ConnectFileStorage.dart'; +import '../comms/ConnectComms.dart'; +import '../memory/ConnectInternalMemory.dart'; + +/// The single gateway every ViewModel talks to. It is the union of the three +/// capability contracts β€” remote (SSH), local persistence, and local files β€” so +/// a screen never reaches for a service directly and no screen needs to know +/// which of the three a given call lands on. +abstract class DataManager + implements ConnectInternalMemory, ConnectComms, ConnectFileStorage {} diff --git a/lib/Peach/memory/ConfigStore.dart b/lib/Peach/memory/ConfigStore.dart new file mode 100644 index 0000000..02b314a --- /dev/null +++ b/lib/Peach/memory/ConfigStore.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../about/internal/application/ServerProfile.dart'; +import 'SecretStore.dart'; + +/// Persists server profiles (with their nested services) locally. Non-secret +/// data goes to shared_preferences; passwords go to encrypted [SecretStore]. +class ConfigStore { + static const _key = 'peach.servers.v2'; + + /// The key used before the app was renamed. Read once and migrated forward, + /// so an existing install keeps its servers. + static const _legacyKey = 'jarpilot.servers.v2'; + + final _secrets = SecretStore(); + + Future> load() async { + final prefs = await SharedPreferences.getInstance(); + var raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) { + raw = prefs.getString(_legacyKey); + if (raw != null && raw.isNotEmpty) { + await prefs.setString(_key, raw); + await prefs.remove(_legacyKey); + } + } + if (raw == null || raw.isEmpty) return []; + final list = jsonDecode(raw) as List; + final servers = list + .map((e) => ServerProfile.fromJson(e as Map)) + .toList(); + // Hydrate passwords from encrypted storage. + for (final s in servers) { + s.password = await _secrets.readPassword(s.id) ?? ''; + } + return servers; + } + + Future save(List servers) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key, + jsonEncode(servers.map((s) => s.toJson()).toList()), + ); + for (final s in servers) { + await _secrets.writePassword(s.id, s.password); + } + } + + Future deleteSecret(String serverId) => + _secrets.deletePassword(serverId); +} diff --git a/lib/Peach/memory/ConnectInternalMemory.dart b/lib/Peach/memory/ConnectInternalMemory.dart new file mode 100644 index 0000000..69c23a9 --- /dev/null +++ b/lib/Peach/memory/ConnectInternalMemory.dart @@ -0,0 +1,24 @@ +import '../about/internal/application/DeploymentRecord.dart'; +import '../about/internal/application/ServerProfile.dart'; + +/// Everything Peach persists on this device. Server profiles and their nested +/// services live here; passwords are held separately in the OS keystore and +/// hydrated onto the profiles on read, so a caller never handles the two +/// stores independently. +abstract class ConnectInternalMemory { + Future> getServers(); + + Future setServers(List servers); + + Future deleteServerSecret(String serverId); + + Future> getDeployments(); + + Future> addDeployment(DeploymentRecord record); + + Future clearDeployments(); + + Future getThemeMode(); + + Future setThemeMode(String mode); +} diff --git a/lib/Peach/memory/DeploymentStore.dart b/lib/Peach/memory/DeploymentStore.dart new file mode 100644 index 0000000..40f24c2 --- /dev/null +++ b/lib/Peach/memory/DeploymentStore.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../about/internal/application/DeploymentRecord.dart'; + +/// Persists the deployment history so successes/failures can be tracked. +class DeploymentStore { + static const _key = 'peach.deployments.v1'; + + /// The key used before the app was renamed; migrated forward on first read. + static const _legacyKey = 'jarpilot.deployments.v1'; + + static const _max = 300; + + Future> load() async { + final prefs = await SharedPreferences.getInstance(); + var raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) { + raw = prefs.getString(_legacyKey); + if (raw != null && raw.isNotEmpty) { + await prefs.setString(_key, raw); + await prefs.remove(_legacyKey); + } + } + if (raw == null || raw.isEmpty) return []; + final list = jsonDecode(raw) as List; + final records = list + .map((e) => DeploymentRecord.fromJson(e as Map)) + .toList(); + records.sort((a, b) => b.time.compareTo(a.time)); + return records; + } + + Future> add(DeploymentRecord record) async { + final records = await load(); + records.insert(0, record); + if (records.length > _max) records.removeRange(_max, records.length); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key, + jsonEncode(records.map((r) => r.toJson()).toList()), + ); + return records; + } + + Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_key); + } +} diff --git a/lib/Peach/memory/InternalMemory.dart b/lib/Peach/memory/InternalMemory.dart new file mode 100644 index 0000000..f5793a9 --- /dev/null +++ b/lib/Peach/memory/InternalMemory.dart @@ -0,0 +1,49 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +import '../about/internal/application/DeploymentRecord.dart'; +import '../about/internal/application/ServerProfile.dart'; +import 'ConfigStore.dart'; +import 'ConnectInternalMemory.dart'; +import 'DeploymentStore.dart'; + +/// The one concrete [ConnectInternalMemory]. Non-secret data goes to +/// shared_preferences via the two stores; passwords go to the OS keystore via +/// [SecretStore] β€” reads hydrate them back onto the profiles so callers only +/// ever see whole [ServerProfile]s. +class InternalMemory implements ConnectInternalMemory { + static const _themeKey = 'peach.thememode'; + + final ConfigStore _config = ConfigStore(); + final DeploymentStore _deployments = DeploymentStore(); + + @override + Future> getServers() => _config.load(); + + @override + Future setServers(List servers) => _config.save(servers); + + @override + Future deleteServerSecret(String serverId) => _config.deleteSecret(serverId); + + @override + Future> getDeployments() => _deployments.load(); + + @override + Future> addDeployment(DeploymentRecord record) => + _deployments.add(record); + + @override + Future clearDeployments() => _deployments.clear(); + + @override + Future getThemeMode() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_themeKey) ?? 'dark'; + } + + @override + Future setThemeMode(String mode) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_themeKey, mode); + } +} diff --git a/lib/Peach/memory/SecretStore.dart b/lib/Peach/memory/SecretStore.dart new file mode 100644 index 0000000..8cc2e6d --- /dev/null +++ b/lib/Peach/memory/SecretStore.dart @@ -0,0 +1,45 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Encrypted storage backed by the OS keystore: Keychain (macOS/iOS), +/// Keystore/EncryptedSharedPreferences (Android), DPAPI (Windows) and +/// libsecret (Linux). Server passwords live here; so does anything else the +/// `internal_memory` brick generates with `secure: true`. +class SecretStore { + static const _storage = FlutterSecureStorage(aOptions: AndroidOptions()); + + static String _passwordKey(String serverId) => 'peach.pw.$serverId'; + + /// The key this app used before it was renamed. Reads fall back to it once + /// and migrate the value forward, so an existing install doesn't silently + /// lose every stored password on upgrade. + static String _legacyPasswordKey(String serverId) => 'jarpilot.pw.$serverId'; + + Future readPassword(String serverId) async { + final current = await _storage.read(key: _passwordKey(serverId)); + if (current != null) return current; + + final legacy = await _storage.read(key: _legacyPasswordKey(serverId)); + if (legacy == null) return null; + await _storage.write(key: _passwordKey(serverId), value: legacy); + await _storage.delete(key: _legacyPasswordKey(serverId)); + return legacy; + } + + Future writePassword(String serverId, String password) => + _storage.write(key: _passwordKey(serverId), value: password); + + Future deletePassword(String serverId) async { + await _storage.delete(key: _passwordKey(serverId)); + await _storage.delete(key: _legacyPasswordKey(serverId)); + } + + // ── generic access ───────────────────────────────────────────────────────── + // Used by fields the `internal_memory` brick generates as secure. + + static Future read(String key) => _storage.read(key: key); + + static Future write(String key, String value) => + _storage.write(key: key, value: value); + + static Future delete(String key) => _storage.delete(key: key); +} diff --git a/lib/Peach/see/configuration/Configuration.dart b/lib/Peach/see/configuration/Configuration.dart new file mode 100644 index 0000000..033df92 --- /dev/null +++ b/lib/Peach/see/configuration/Configuration.dart @@ -0,0 +1,16 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import 'ConfigurationState.dart'; + +/// Probes the server's environment (Java, MariaDB, Postgres, nginx, Docker, +/// Node, Git), shows what's present, and guides installing what's missing +/// using the auto-detected package manager β€” preview, then confirm. +class Configuration extends StatefulWidget { + final ServerProfile server; + + const Configuration({super.key, required this.server}); + + @override + State createState() => ConfigurationState(); +} diff --git a/lib/Peach/see/configuration/ConfigurationState.dart b/lib/Peach/see/configuration/ConfigurationState.dart new file mode 100644 index 0000000..0b21291 --- /dev/null +++ b/lib/Peach/see/configuration/ConfigurationState.dart @@ -0,0 +1,406 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/EnvCheck.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/StepTracker.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../comms/Comms.dart' show LogLine; +import '../../designs/LogConsole.dart'; +import '../../designs/StepList.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import 'Configuration.dart'; +import 'ConnectConfiguration.dart'; +import 'ViewConfiguration.dart'; + +class ConfigurationState extends State + implements ConnectConfiguration { + ViewConfiguration? _model; + + StreamSubscription? _logSub; + StreamSubscription>? _stepSub; + + final _lines = []; + List _steps = []; + final Map _versions = {}; + + String _packageManager = 'unknown'; + bool _loading = true; + String? _error; + String? _busyKey; + + ServerProfile get _server => widget.server; + + bool _installed(String key) => (_versions[key] ?? '').trim().isNotEmpty; + + @override + void dispose() { + _logSub?.cancel(); + _stepSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewConfiguration(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + '${_server.name} Β· configuration', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + IconButton( + tooltip: 'Re-scan', + onPressed: _loading || _busyKey != null ? null : _onRescan, + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 4), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), + children: [ + _summaryCard(), + if (_error != null) ...[const SizedBox(height: 12), _errorCard()], + const SizedBox(height: 16), + text( + 'ENVIRONMENT', + 12, + TextType.Bold, + color: context.jp.textDim, + letterSpacing: 1.2, + ), + const SizedBox(height: 10), + LayoutBuilder( + builder: (context, c) { + const gap = 12.0; + final cols = c.maxWidth >= 720 ? 2 : 1; + final w = (c.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final tool in kEnvTools) + SizedBox(width: w, child: _toolCard(tool)), + ], + ); + }, + ), + if (_steps.isNotEmpty) ...[ + const SizedBox(height: 22), + StepList(steps: _steps), + ], + if (_lines.isNotEmpty) ...[ + const SizedBox(height: 14), + LogConsole( + logs: _lines, + height: 260, + onClear: _onClearLogs, + onCopy: _onCopyLogs, + ), + ], + ], + ), + ), + ); + } + + void _onReady(ViewConfiguration model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _logSub = _model?.logs.listen(_onLogLine); + _stepSub = _model?.steps.listen(_onSteps); + _model?.probeEnvironment(_server); + } + + void _onLogLine(LogLine line) { + if (!mounted) return; + setState(() => _lines.add(line)); + } + + void _onSteps(List steps) { + if (!mounted) return; + setState(() => _steps = steps); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onRescan() { + setState(() { + _loading = true; + _error = null; + }); + _model?.probeEnvironment(_server); + } + + void _onInstall(EnvTool tool) => + _model?.prepareInstall(tool, _packageManager); + + void _onClearLogs() => setState(() => _lines.clear()); + + void _onCopyLogs() { + Clipboard.setData( + ClipboardData(text: _lines.map((l) => l.text).join('\n')), + ); + _notify('Logs copied.'); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectConfiguration ─────────────────────────────────────────────────── + + @override + void onEnvironmentProbed( + String packageManager, + Map versions, + ) { + if (!mounted) return; + setState(() { + _packageManager = packageManager; + _versions + ..clear() + ..addAll(versions); + _loading = false; + _error = null; + }); + } + + @override + void onProbeFailed(String message) { + if (!mounted) return; + setState(() { + _loading = false; + _error = message; + }); + } + + @override + void onInstallCommandReady(EnvTool tool, String command) { + final sudoNote = _server.useSudo ? ' (via sudo)' : ''; + final unitNote = tool.unit == null + ? '' + : '\n\nThen: systemctl enable --now ${tool.unit}'; + _model?.showApplicationNotification( + NotificationType.info, + 'Install ${tool.label}?', + 'Peach will run$sudoNote:\n\n$command$unitNote', + true, + true, + null, + action: 'Install', + positiveAction: () => _onConfirmInstall(tool, command), + ); + } + + void _onConfirmInstall(EnvTool tool, String command) { + Navigator.pop(context); + setState(() { + _busyKey = tool.key; + _lines.clear(); + }); + _model?.installTool(_server, tool, command); + } + + @override + void onToolInstalled(EnvTool tool, String version) { + if (!mounted) return; + setState(() { + _versions[tool.key] = version; + _busyKey = null; + }); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _summaryCard() { + final jp = context.jp; + final present = kEnvTools.where((t) => _installed(t.key)).length; + return Container( + padding: const EdgeInsets.all(16), + decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed), + child: Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: PeachTheme.seed.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(13), + border: Border.all( + color: PeachTheme.seed.withValues(alpha: 0.35), + ), + ), + child: const Icon(Icons.fact_check, color: PeachTheme.seed), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + _loading + ? 'Scanning environment…' + : '$present / ${kEnvTools.length} tools ready', + 16, + TextType.Bold, + color: jp.text, + ), + const SizedBox(height: 2), + text( + '${_server.username}@${_server.host}', + 12, + TextType.Regular, + color: jp.textDim, + ), + ], + ), + ), + _packageManagerChip(), + ], + ), + ); + } + + Widget _packageManagerChip() { + final jp = context.jp; + final known = _packageManager != 'unknown'; + final color = known ? PeachTheme.accent : jp.textFaint; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inventory_2, size: 13, color: color), + const SizedBox(width: 5), + text( + _loading ? '…' : _packageManager, + 11.5, + TextType.Bold, + color: color, + ), + ], + ), + ); + } + + Widget _errorCard() => Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: PeachTheme.danger.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: PeachTheme.danger.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: PeachTheme.danger), + const SizedBox(width: 10), + Expanded( + child: text(_error!, 14, TextType.Regular, color: PeachTheme.danger), + ), + ], + ), + ); + + Widget _toolCard(EnvTool tool) { + final jp = context.jp; + final installed = _installed(tool.key); + final version = _versions[tool.key]?.trim() ?? ''; + final busy = _busyKey == tool.key; + final color = installed ? PeachTheme.seed : jp.textFaint; + return Container( + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 16), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(11), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Icon(tool.icon, color: color, size: 20), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text(tool.label, 15, TextType.Bold, color: jp.text), + const SizedBox(height: 2), + text( + _loading + ? 'checking…' + : (installed + ? (version.isEmpty ? 'installed' : version) + : 'Not installed'), + 12, + TextType.Regular, + color: installed ? jp.textDim : jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 10), + if (_loading) + const SizedBox() + else if (installed) + const Icon(Icons.check_circle, color: PeachTheme.seed) + else if (busy) + const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + OutlinedButton.icon( + onPressed: _busyKey != null ? null : () => _onInstall(tool), + icon: const Icon(Icons.download, size: 18), + label: text('Install', 13, TextType.Bold, color: PeachTheme.seed), + style: OutlinedButton.styleFrom( + foregroundColor: PeachTheme.seed, + side: BorderSide(color: PeachTheme.seed.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/Peach/see/configuration/ConnectConfiguration.dart b/lib/Peach/see/configuration/ConnectConfiguration.dart new file mode 100644 index 0000000..7b00e7f --- /dev/null +++ b/lib/Peach/see/configuration/ConnectConfiguration.dart @@ -0,0 +1,16 @@ +import '../../about/external/data/EnvCheck.dart'; + +abstract class ConnectConfiguration { + /// The environment scan finished: the detected package manager and the + /// version string found for each tool (empty when absent). + void onEnvironmentProbed(String packageManager, Map versions); + + /// The scan failed. + void onProbeFailed(String message); + + /// The exact command that would install [tool], for the user to confirm. + void onInstallCommandReady(EnvTool tool, String command); + + /// [tool] finished installing; [version] is its freshly re-probed version. + void onToolInstalled(EnvTool tool, String version); +} diff --git a/lib/Peach/see/configuration/ViewConfiguration.dart b/lib/Peach/see/configuration/ViewConfiguration.dart new file mode 100644 index 0000000..96ae7ac --- /dev/null +++ b/lib/Peach/see/configuration/ViewConfiguration.dart @@ -0,0 +1,79 @@ +import '../../about/external/data/EnvCheck.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/StepTracker.dart'; +import '../../comms/Comms.dart' show LogLine; +import '../parent/ParentViewModel.dart'; +import 'ConnectConfiguration.dart'; + +class ViewConfiguration extends ParentViewModel { + ConnectConfiguration connection; + + ViewConfiguration(super.context, this.connection); + + Stream get logs => getDataManager().logs; + + Stream> get steps => getDataManager().stepStream; + + /// Detects the package manager, then probes for every tool at once. + void probeEnvironment(ServerProfile server) async { + if (!await canReach(server, () => probeEnvironment(server))) return; + try { + final packageManager = await getDataManager().detectPackageManager( + server, + ); + final versions = await getDataManager().probeEnvironment( + server, + kEnvTools.map((t) => '${t.key} ${t.detect}').toList(), + ); + connection.onEnvironmentProbed(packageManager, versions); + } catch (error) { + connection.onProbeFailed(error.toString()); + } + } + + /// Builds the install command for [tool] under [packageManager] and hands it + /// back for confirmation β€” nothing runs on the server until the user agrees. + void prepareInstall(EnvTool tool, String packageManager) { + if (packageManager == 'unknown') { + connection.onProbeFailed( + 'Unknown package manager β€” cannot auto-install.', + ); + return; + } + connection.onInstallCommandReady( + tool, + installCommandFor(packageManager, tool.packagesFor(packageManager)), + ); + } + + /// Installs [tool], enables its unit where it has one, then re-probes just + /// that tool so the card updates without a full re-scan. + void installTool(ServerProfile server, EnvTool tool, String command) async { + try { + await getDataManager().installPackages( + server: server, + command: command, + enableUnit: tool.unit, + ); + var version = ''; + try { + final probed = await getDataManager().probeEnvironment(server, [ + '${tool.key} ${tool.detect}', + ]); + version = probed[tool.key] ?? ''; + } catch (_) { + // The install itself succeeded; a failed re-probe just leaves the + // version blank until the next full scan. + } + connection.onToolInstalled(tool, version); + } catch (error) { + connection.onToolInstalled(tool, ''); + handleError( + error, + () => installTool(server, tool, command), + () {}, + "Retry", + ); + } + } +} diff --git a/lib/Peach/see/deploy/batch/BatchDeploy.dart b/lib/Peach/see/deploy/batch/BatchDeploy.dart new file mode 100644 index 0000000..25847e8 --- /dev/null +++ b/lib/Peach/see/deploy/batch/BatchDeploy.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import 'BatchDeployState.dart'; + +/// Batch "build all & deploy": pick which services to include, choose a mode, +/// and run them one after another with live per-service status. +class BatchDeploy extends StatefulWidget { + final ServerProfile server; + + const BatchDeploy({super.key, required this.server}); + + @override + State createState() => BatchDeployState(); +} diff --git a/lib/Peach/see/deploy/batch/BatchDeployState.dart b/lib/Peach/see/deploy/batch/BatchDeployState.dart new file mode 100644 index 0000000..007ddfe --- /dev/null +++ b/lib/Peach/see/deploy/batch/BatchDeployState.dart @@ -0,0 +1,549 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/ServiceConfig.dart'; +import '../../../about/internal/application/StepTracker.dart'; +import '../../../about/internal/application/TextType.dart'; +import '../../../comms/Comms.dart' show LogLine; +import '../../../designs/LogConsole.dart'; +import '../../../designs/StepList.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import 'BatchDeploy.dart'; +import 'ConnectBatchDeploy.dart'; +import 'ViewBatchDeploy.dart'; + +enum BatchMode { buildDeploy, buildOnly } + +class BatchDeployState extends State + implements ConnectBatchDeploy { + ViewBatchDeploy? _model; + + final _subscriptions = []; + final _lines = []; + List _steps = []; + + final Set _selected = {}; + final Map _status = {}; + final Map _detail = {}; + + late List _ordered; + BatchMode _mode = BatchMode.buildDeploy; + bool _running = false; + + ServerProfile get _server => widget.server; + + /// A service can join a batch only if Peach knows how to build it. + bool _eligible(ServiceConfig s) => s.canBuild; + + int get _eligibleCount => _ordered.where(_eligible).length; + + int get _doneCount => + _status.values.where((s) => s == BatchState.done).length; + + int get _failCount => + _status.values.where((s) => s == BatchState.failed).length; + + @override + void initState() { + super.initState(); + _ordered = _server.orderedServices; + for (final service in _ordered) { + if (_eligible(service)) _selected.add(service.id); + } + } + + @override + void dispose() { + for (final s in _subscriptions) { + s.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewBatchDeploy(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + 'Build & deploy Β· ${_server.name}', + 20, + TextType.Bold, + color: context.jp.text, + ), + ), + body: Column( + children: [ + _header(), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + children: [ + _reorderHint(), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + buildDefaultDragHandles: false, + onReorderItem: _onReorder, + itemCount: _ordered.length, + itemBuilder: (context, i) => Padding( + key: ValueKey(_ordered[i].id), + padding: const EdgeInsets.only(bottom: 10), + child: _serviceTile(_ordered[i], i), + ), + ), + if (_steps.isNotEmpty) ...[ + const SizedBox(height: 14), + StepList(steps: _steps), + ], + if (_lines.isNotEmpty) ...[ + const SizedBox(height: 14), + LogConsole( + logs: _lines, + height: 300, + onClear: _onClearLogs, + onCopy: _onCopyLogs, + ), + ], + ], + ), + ), + _bottomBar(), + ], + ), + ), + ); + } + + void _onReady(ViewBatchDeploy model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + final model = _model; + if (model == null) return; + _subscriptions.addAll([ + model.logs.listen(_onLogLine), + model.buildLogs.listen(_onLogLine), + model.steps.listen(_onSteps), + model.buildSteps.listen(_onSteps), + ]); + } + + void _onLogLine(LogLine line) { + if (!mounted) return; + setState(() => _lines.add(line)); + } + + void _onSteps(List steps) { + if (!mounted) return; + setState(() => _steps = steps); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + /// [newIndex] already accounts for the removed item β€” `onReorderItem` does + /// that adjustment for us, unlike the older `onReorder`. + void _onReorder(int oldIndex, int newIndex) { + setState(() { + final moved = _ordered.removeAt(oldIndex); + _ordered.insert(newIndex, moved); + }); + _model?.saveDeployOrder(_server, _ordered.map((s) => s.id).toList()); + } + + void _onRun() { + final batch = _ordered.where((s) => _selected.contains(s.id)).toList(); + if (batch.isEmpty) { + _notify('Select at least one service.'); + return; + } + setState(() { + _running = true; + _lines.clear(); + }); + _model?.runBatch( + _server, + batch, + deployAfterBuild: _mode == BatchMode.buildDeploy, + ); + } + + void _onStop() => _model?.requestCancel(); + + void _onSelectMode(BatchMode mode) => setState(() => _mode = mode); + + void _onToggleService(ServiceConfig service, bool? selected) { + setState(() { + if (selected == true) { + _selected.add(service.id); + } else { + _selected.remove(service.id); + } + }); + } + + void _onToggleAll() { + final eligible = _ordered.where(_eligible).map((s) => s.id).toSet(); + setState(() { + if (_selected.length >= eligible.length) { + _selected.clear(); + } else { + _selected + ..clear() + ..addAll(eligible); + } + }); + } + + void _onClearLogs() => setState(() => _lines.clear()); + + void _onCopyLogs() { + Clipboard.setData( + ClipboardData(text: _lines.map((l) => l.text).join('\n')), + ); + _notify('Logs copied.'); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectBatchDeploy ───────────────────────────────────────────────────── + + @override + void onBatchStarted(List serviceIds) { + if (!mounted) return; + setState(() { + for (final id in serviceIds) { + _status[id] = BatchState.queued; + _detail.remove(id); + } + }); + } + + @override + void onServiceState(String serviceId, BatchState state, {String? detail}) { + if (!mounted) return; + setState(() { + _status[serviceId] = state; + if (detail != null) _detail[serviceId] = detail; + }); + } + + @override + void onBatchFinished() { + if (!mounted) return; + setState(() => _running = false); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _header() { + final jp = context.jp; + final allSelected = + _selected.length >= _eligibleCount && _eligibleCount > 0; + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: jp.surface, + border: Border(bottom: BorderSide(color: jp.border)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + text('Mode', 13, TextType.Bold, color: jp.textDim), + const Spacer(), + if (_running || _doneCount + _failCount > 0) + text( + '${_doneCount + _failCount}/${_selected.length} Β· ' + '$_doneCount ok Β· $_failCount failed', + 12, + TextType.Regular, + color: jp.textFaint, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _modeChip( + BatchMode.buildDeploy, + 'Build & deploy', + Icons.rocket_launch, + ), + const SizedBox(width: 8), + _modeChip(BatchMode.buildOnly, 'Build only', Icons.build), + const Spacer(), + TextButton.icon( + onPressed: _running ? null : _onToggleAll, + icon: Icon( + allSelected ? Icons.remove_done : Icons.done_all, + size: 16, + ), + label: text( + allSelected ? 'Clear' : 'Select all', + 13, + TextType.Bold, + color: colorPrimary, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _reorderHint() => Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8, top: 2), + child: Row( + children: [ + Icon(Icons.drag_indicator, size: 15, color: context.jp.textFaint), + const SizedBox(width: 4), + text( + 'Drag to set deployment order β€” saved automatically', + 12, + TextType.Regular, + color: context.jp.textFaint, + ), + ], + ), + ); + + Widget _modeChip(BatchMode mode, String label, IconData icon) { + final jp = context.jp; + final active = _mode == mode; + return GestureDetector( + onTap: _running ? null : () => _onSelectMode(mode), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: active ? PeachTheme.seed.withValues(alpha: 0.14) : jp.surface2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: active ? PeachTheme.seed : jp.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: active ? PeachTheme.seed : jp.textDim), + const SizedBox(width: 6), + text( + label, + 12.5, + TextType.Bold, + color: active ? PeachTheme.seed : jp.textDim, + ), + ], + ), + ), + ); + } + + Widget _serviceTile(ServiceConfig service, int index) { + final jp = context.jp; + final eligible = _eligible(service); + final selected = _selected.contains(service.id); + final state = _status[service.id]; + return Container( + padding: const EdgeInsets.fromLTRB(8, 12, 12, 12), + decoration: PeachTheme.panel(context, radius: 14), + child: Row( + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: PeachTheme.seed.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(7), + ), + child: text( + '${index + 1}', + 12, + TextType.Bold, + color: PeachTheme.seed, + ), + ), + const SizedBox(width: 4), + if (eligible) + Checkbox( + value: selected, + onChanged: _running ? null : (v) => _onToggleService(service, v), + ) + else + const SizedBox(width: 40, child: Icon(Icons.block, size: 18)), + Icon( + service.isWeb + ? Icons.language + : (service.isGeneric ? Icons.terminal : Icons.settings_suggest), + size: 18, + color: jp.textDim, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + service.name, + 14, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + text( + eligible + ? (_detail[service.id] ?? + '${service.kind.label} Β· builds from source') + : 'Add a source path to include', + 11.5, + TextType.Regular, + color: jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + _statusPill(state), + if (!_running) ...[ + const SizedBox(width: 4), + ReorderableDragStartListener( + index: index, + child: Icon(Icons.drag_handle, size: 20, color: jp.textFaint), + ), + ], + ], + ), + ); + } + + Widget _statusPill(BatchState? state) { + if (state == null) return const SizedBox.shrink(); + final jp = context.jp; + final (color, label, spinning) = switch (state) { + BatchState.queued => (jp.textFaint, 'QUEUED', false), + BatchState.building => (PeachTheme.warn, 'BUILDING', true), + BatchState.deploying => (PeachTheme.accent, 'DEPLOYING', true), + BatchState.done => (PeachTheme.seed, 'DONE', false), + BatchState.failed => (PeachTheme.danger, 'FAILED', false), + BatchState.skipped => (jp.textFaint, 'SKIPPED', false), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (spinning) + SizedBox( + width: 11, + height: 11, + child: CircularProgressIndicator(strokeWidth: 2, color: color), + ) + else + Icon( + state == BatchState.done + ? Icons.check + : state == BatchState.failed + ? Icons.close + : Icons.circle, + size: 11, + color: color, + ), + const SizedBox(width: 5), + text(label, 10, TextType.Bold, color: color), + ], + ), + ); + } + + Widget _bottomBar() { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _running || _selected.isEmpty ? null : _onRun, + icon: _running + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorWhite, + ), + ) + : Icon( + _mode == BatchMode.buildOnly + ? Icons.build + : Icons.rocket_launch, + ), + label: text( + _running + ? 'Running…' + : (_mode == BatchMode.buildOnly + ? 'Build ${_selected.length} selected' + : 'Build & deploy ${_selected.length} selected'), + 14, + TextType.Bold, + color: colorWhite, + ), + ), + ), + if (_running) ...[ + const SizedBox(width: 10), + OutlinedButton.icon( + onPressed: _onStop, + icon: const Icon(Icons.cancel, size: 18), + label: text( + 'Stop', + 13, + TextType.Bold, + color: PeachTheme.danger, + ), + style: OutlinedButton.styleFrom( + foregroundColor: PeachTheme.danger, + side: BorderSide( + color: PeachTheme.danger.withValues(alpha: 0.6), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/Peach/see/deploy/batch/ConnectBatchDeploy.dart b/lib/Peach/see/deploy/batch/ConnectBatchDeploy.dart new file mode 100644 index 0000000..fd35d46 --- /dev/null +++ b/lib/Peach/see/deploy/batch/ConnectBatchDeploy.dart @@ -0,0 +1,13 @@ +/// Where one service has got to in the batch. +enum BatchState { queued, building, deploying, done, failed, skipped } + +abstract class ConnectBatchDeploy { + /// The run started; every selected service is queued. + void onBatchStarted(List serviceIds); + + /// One service moved to a new state, optionally with a detail line. + void onServiceState(String serviceId, BatchState state, {String? detail}); + + /// Every service in the batch has been attempted. + void onBatchFinished(); +} diff --git a/lib/Peach/see/deploy/batch/ViewBatchDeploy.dart b/lib/Peach/see/deploy/batch/ViewBatchDeploy.dart new file mode 100644 index 0000000..3d4ed3d --- /dev/null +++ b/lib/Peach/see/deploy/batch/ViewBatchDeploy.dart @@ -0,0 +1,180 @@ +import 'dart:io'; + +import 'package:uuid/uuid.dart'; + +import '../../../about/internal/application/DeploymentRecord.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/ServiceConfig.dart'; +import '../../../about/internal/application/StepTracker.dart'; +import '../../../comms/Comms.dart' show DeployResult, LogLine; +import '../../../utils/CommonUtils.dart'; +import '../../parent/ParentViewModel.dart'; +import 'ConnectBatchDeploy.dart'; + +class ViewBatchDeploy extends ParentViewModel { + ConnectBatchDeploy connection; + + ViewBatchDeploy(super.context, this.connection); + + bool _cancelRequested = false; + + Stream get logs => getDataManager().logs; + + Stream get buildLogs => getDataManager().buildLogs; + + Stream> get steps => getDataManager().stepStream; + + Stream> get buildSteps => getDataManager().buildStepStream; + + /// Builds (and optionally deploys) each service in [batch], in order. One + /// service failing doesn't stop the rest β€” the run reports per-service. + void runBatch( + ServerProfile server, + List batch, { + required bool deployAfterBuild, + }) async { + if (deployAfterBuild && + !await canReach( + server, + () => runBatch(server, batch, deployAfterBuild: deployAfterBuild), + )) { + return; + } + + _cancelRequested = false; + connection.onBatchStarted(batch.map((s) => s.id).toList()); + + for (final service in batch) { + if (_cancelRequested) { + connection.onServiceState(service.id, BatchState.skipped); + continue; + } + await _runOne(server, service, deployAfterBuild); + } + + connection.onBatchFinished(); + } + + Future _runOne( + ServerProfile server, + ServiceConfig service, + bool deployAfterBuild, + ) async { + connection.onServiceState(service.id, BatchState.building); + + final built = service.isWeb + ? await getDataManager().buildWeb(service) + : await getDataManager().build(service); + + if (!built.ok) { + connection.onServiceState( + service.id, + BatchState.failed, + detail: built.message, + ); + return; + } + + if (!deployAfterBuild) { + connection.onServiceState(service.id, BatchState.done, detail: 'Built'); + return; + } + + connection.onServiceState(service.id, BatchState.deploying); + + DeployResult result; + String artifact; + try { + if (service.isWeb) { + final directory = built.dir!.path; + if (!File('$directory/index.html').existsSync()) { + connection.onServiceState( + service.id, + BatchState.failed, + detail: 'No index.html in build output.', + ); + return; + } + final (bytes, count) = await getDataManager().packageWeb(directory); + artifact = service.framework.label; + result = await getDataManager().deployWeb( + server: server, + service: service, + archiveBytes: bytes, + fileCount: count, + ); + } else { + final jar = built.jar!; + artifact = baseName(jar.path); + result = await getDataManager().deploy( + server: server, + service: service, + jarBytes: await jar.readAsBytes(), + jarLabel: artifact, + ); + } + } catch (error) { + connection.onServiceState( + service.id, + BatchState.failed, + detail: 'Error: $error', + ); + return; + } + + await _record(server, service, result, artifact); + connection.onServiceState( + service.id, + result.ok ? BatchState.done : BatchState.failed, + detail: result.message, + ); + } + + /// Stops after the service currently running finishes. + void requestCancel() { + _cancelRequested = true; + getDataManager().requestCancel(); + getDataManager().cancelBuild(); + } + + /// Remembers the order services should be deployed in next time. + void saveDeployOrder(ServerProfile server, List ids) async { + try { + final servers = await getDataManager().getServers(); + final index = servers.indexWhere((s) => s.id == server.id); + if (index < 0) return; + servers[index].deployOrder = ids; + await getDataManager().setServers(servers); + } catch (error) { + handleError(error, () => saveDeployOrder(server, ids), () {}, "Retry"); + } + } + + Future _record( + ServerProfile server, + ServiceConfig service, + DeployResult result, + String artifact, + ) async { + try { + await getDataManager().addDeployment( + DeploymentRecord( + id: const Uuid().v4(), + serverId: server.id, + serverName: server.name, + serviceId: service.id, + serviceLabel: service.name, + jarName: artifact, + remotePath: service.isWeb + ? service.remoteRoot + : service.remotePath(artifact), + time: DateTime.now(), + status: result.ok ? DeployStatus.success : DeployStatus.failure, + message: result.message, + ), + ); + } catch (_) { + // A missing history row must not change what the batch reports. + } + } +} diff --git a/lib/Peach/see/deploy/single/ConnectDeploy.dart b/lib/Peach/see/deploy/single/ConnectDeploy.dart new file mode 100644 index 0000000..47e8ef4 --- /dev/null +++ b/lib/Peach/see/deploy/single/ConnectDeploy.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; + +import '../../../comms/Comms.dart' show DeployResult; + +abstract class ConnectDeploy { + /// nginx's configured roots, used to warn when a web service deploys + /// somewhere nginx isn't serving. + void onNginxRootsLoaded(List roots); + + /// A jar / binary is staged and ready to ship. + void onJarStaged(Uint8List bytes, String name, String source); + + /// A built site directory is staged and ready to ship. + void onWebStaged(String directory, String source); + + /// A local build finished; [message] explains a failure. + void onBuildFailed(String message); + + /// Packaging a web build began (it can take a while on big sites). + void onPackagingStarted(); + + /// The deploy finished, successfully or not. + void onDeployFinished(DeployResult result); + + /// A revert finished. + void onRevertFinished(DeployResult result); + + /// The kept-backup was discarded. + void onBackupDiscarded(); + + /// Live tail started or stopped. + void onFollowChanged(bool following); +} diff --git a/lib/Peach/see/deploy/single/Deploy.dart b/lib/Peach/see/deploy/single/Deploy.dart new file mode 100644 index 0000000..29cd651 --- /dev/null +++ b/lib/Peach/see/deploy/single/Deploy.dart @@ -0,0 +1,17 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/ServiceConfig.dart'; +import 'DeployState.dart'; + +/// Stage an artifact β€” dropped, picked, or built from source β€” then ship it to +/// the server, with live steps, transfer stats and a revert path. +class Deploy extends StatefulWidget { + final ServerProfile server; + final ServiceConfig service; + + const Deploy({super.key, required this.server, required this.service}); + + @override + State createState() => DeployState(); +} diff --git a/lib/Peach/see/deploy/single/DeployState.dart b/lib/Peach/see/deploy/single/DeployState.dart new file mode 100644 index 0000000..f665a6d --- /dev/null +++ b/lib/Peach/see/deploy/single/DeployState.dart @@ -0,0 +1,1030 @@ +import 'dart:async'; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/ServiceConfig.dart'; +import '../../../about/internal/application/StepTracker.dart'; +import '../../../about/internal/application/TextType.dart'; +import '../../../comms/Comms.dart' show DeployResult, LogLine, TransferProgress; +import '../../../designs/LogConsole.dart'; +import '../../../designs/StepList.dart'; +import '../../../designs/TransferCard.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import '../../../utils/CommonUtils.dart'; +import 'ConnectDeploy.dart'; +import 'Deploy.dart'; +import 'ViewDeploy.dart'; + +enum DeployPhase { idle, building, packaging, deploying, reverting, done } + +class DeployState extends State implements ConnectDeploy { + ViewDeploy? _model; + + final _subscriptions = []; + final _lines = []; + List _steps = []; + TransferProgress? _transfer; + + // Staged artifact β€” one of these, never both. + Uint8List? _jarBytes; + String? _jarName; + String? _webDir; + String _source = ''; + + late String _projectPath; + List? _nginxRoots; + + bool _dragging = false; + bool _following = false; + bool _succeeded = false; + DeployPhase _phase = DeployPhase.idle; + DeployResult? _lastResult; + + ServerProfile get _server => widget.server; + ServiceConfig get _service => widget.service; + bool get _isWeb => _service.isWeb; + bool get _isGeneric => _service.isGeneric; + + bool get _ready => _isWeb ? _webDir != null : _jarBytes != null; + + bool get _busy => + _phase == DeployPhase.building || + _phase == DeployPhase.packaging || + _phase == DeployPhase.deploying || + _phase == DeployPhase.reverting; + + String get _artifactName => + _isWeb ? (_webDir == null ? '' : baseName(_webDir!)) : (_jarName ?? ''); + + String get _resolvedTarget => + _isWeb ? _service.remoteRoot : _service.remotePath(_jarName ?? ''); + + /// nginx is configured to serve somewhere this service doesn't deploy to β€” + /// the upload will succeed but the site won't change. + bool get _rootMismatch => + _isWeb && + _nginxRoots != null && + _nginxRoots!.isNotEmpty && + !_nginxRoots!.any((r) => _sameDir(r, _service.remoteRoot)); + + bool _sameDir(String a, String b) { + String trim(String s) => + s.endsWith('/') && s.length > 1 ? s.substring(0, s.length - 1) : s; + return trim(a.trim()) == trim(b.trim()); + } + + @override + void initState() { + super.initState(); + _projectPath = _service.localProjectPath; + } + + @override + void dispose() { + for (final s in _subscriptions) { + s.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewDeploy(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + '${_server.name} Β· ${_service.name}', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + if (viewModel.isConnected) + IconButton( + tooltip: 'Disconnect', + onPressed: _onDisconnect, + icon: const Icon(Icons.link_off), + ), + ], + ), + body: Column( + children: [ + _targetHeader(), + Expanded( + child: LayoutBuilder( + builder: (context, c) { + if (c.maxWidth <= 820) { + return ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 20), + children: [ + ..._controls(viewModel), + const SizedBox(height: 14), + _console(expand: false), + ], + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + flex: 5, + child: ListView(children: _controls(viewModel)), + ), + const SizedBox(width: 16), + Expanded(flex: 4, child: _console(expand: true)), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + void _onReady(ViewDeploy model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + final model = _model; + if (model == null) return; + _subscriptions.addAll([ + model.logs.listen(_onLogLine), + model.buildLogs.listen(_onLogLine), + model.steps.listen(_onSteps), + model.buildSteps.listen(_onSteps), + model.transferProgress.listen(_onTransfer), + ]); + if (_isWeb) model.loadNginxRoots(_server); + } + + void _onLogLine(LogLine line) { + if (!mounted) return; + setState(() => _lines.add(line)); + } + + void _onSteps(List steps) { + if (!mounted) return; + setState(() => _steps = steps); + } + + void _onTransfer(TransferProgress? progress) { + if (!mounted) return; + setState(() => _transfer = progress); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onPickArtifact() { + if (_isWeb) { + _model?.pickWebFolder(); + } else { + _model?.pickArtifact(_isGeneric); + } + } + + void _onDragEntered(DropEventDetails _) => setState(() => _dragging = true); + + void _onDragExited(DropEventDetails _) => setState(() => _dragging = false); + + void _onDragDone(DropDoneDetails details) { + setState(() => _dragging = false); + _model?.stageDropped(details, _isWeb, _isGeneric); + } + + void _onPickProjectDir() => _model?.pickProjectDirectory(_server, _service); + + Future _onBuild() async { + if (_projectPath.trim().isEmpty) { + _notify('Set the path to the source code first.'); + return; + } + await _model?.saveProjectPath(_server, _service, _projectPath); + setState(() { + _phase = DeployPhase.building; + _lines.clear(); + _transfer = null; + _lastResult = null; + _succeeded = false; + _following = false; + _jarBytes = null; + _jarName = null; + _webDir = null; + }); + _model?.buildFromSource(_service, _isWeb); + } + + void _onDeploy() { + if (!_ready) { + _notify('Stage an artifact first.'); + return; + } + setState(() { + _phase = DeployPhase.deploying; + _transfer = null; + _lastResult = null; + _following = false; + }); + if (_isWeb) { + _model?.deployWeb(_server, _service, _webDir!); + } else { + _model?.deployJar(_server, _service, _jarBytes!, _jarName!); + } + } + + void _onRevert() { + setState(() => _phase = DeployPhase.reverting); + _model?.revert(_server, _service); + } + + void _onDiscardBackup() => _model?.discardBackup(); + + void _onCancel() { + _model?.cancel(_phase == DeployPhase.building); + _notify('Cancelling…'); + } + + void _onDisconnect() => _model?.disconnect(); + + void _onToggleFollow() => _model?.toggleFollow(_server, _service, _following); + + void _onClearStaged() { + setState(() { + _jarBytes = null; + _jarName = null; + _webDir = null; + _source = ''; + _lastResult = null; + _succeeded = false; + }); + } + + void _onClearLogs() => setState(() => _lines.clear()); + + void _onCopyLogs() { + Clipboard.setData( + ClipboardData(text: _lines.map((l) => l.text).join('\n')), + ); + _notify('Logs copied.'); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectDeploy ────────────────────────────────────────────────────────── + + @override + void onNginxRootsLoaded(List roots) { + if (!mounted) return; + setState(() => _nginxRoots = roots); + } + + @override + void onJarStaged(Uint8List bytes, String name, String source) { + if (!mounted) return; + setState(() { + _jarBytes = bytes; + _jarName = name; + _webDir = null; + _source = source; + _lastResult = null; + _succeeded = false; + _phase = DeployPhase.idle; + }); + if (source == 'Built from source') { + _notify('Build ready β€” proceed to deploy.'); + } + } + + @override + void onWebStaged(String directory, String source) { + if (!mounted) return; + setState(() { + _webDir = directory; + _jarBytes = null; + _jarName = null; + _source = source; + _lastResult = null; + _succeeded = false; + _phase = DeployPhase.idle; + }); + if (source == 'Built from source') { + _notify('Build ready β€” proceed to deploy.'); + } + } + + @override + void onBuildFailed(String message) { + if (!mounted) return; + setState(() { + _phase = DeployPhase.done; + _lastResult = DeployResult(false, false, message); + }); + } + + @override + void onPackagingStarted() { + if (!mounted) return; + setState(() => _phase = DeployPhase.packaging); + } + + @override + void onDeployFinished(DeployResult result) { + if (!mounted) return; + setState(() { + _phase = DeployPhase.done; + _lastResult = result; + _succeeded = result.ok; + }); + } + + @override + void onRevertFinished(DeployResult result) { + if (!mounted) return; + setState(() { + _phase = DeployPhase.done; + _lastResult = result; + }); + } + + @override + void onBackupDiscarded() { + if (!mounted) return; + setState(() => _lastResult = null); + } + + @override + void onFollowChanged(bool following) { + if (!mounted) return; + setState(() => _following = following); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + /// Source chooser (or ready panel), live steps, transfer stats, result. + List _controls(ViewDeploy model) { + return [ + if (_rootMismatch) ...[_rootWarning(), const SizedBox(height: 14)], + if (_ready) _readyPanel() else _sourceChooser(model), + if (_steps.isNotEmpty) ...[ + const SizedBox(height: 14), + StepList(steps: _steps), + ], + if (_transfer != null && !_transfer!.complete) ...[ + const SizedBox(height: 14), + TransferCard(progress: _transfer!), + ], + if (_lastResult != null) ...[ + const SizedBox(height: 14), + _resultBanner(_lastResult!), + ], + ]; + } + + Widget _console({required bool expand}) => LogConsole( + logs: _lines, + height: expand ? null : 320, + live: _following, + onClear: _onClearLogs, + onCopy: _onCopyLogs, + ); + + Widget _rootWarning() { + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + decoration: BoxDecoration( + color: PeachTheme.warn.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: PeachTheme.warn.withValues(alpha: 0.5)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber, color: PeachTheme.warn), + const SizedBox(width: 10), + Expanded( + child: text( + 'nginx serves ${_nginxRoots!.join(', ')} β€” but this service ' + 'deploys to ${_service.remoteRoot}. Files will upload, but the ' + 'site won\'t be served until they match. Edit the service\'s ' + 'web root (or your nginx config).', + 12.5, + TextType.Regular, + color: context.jp.textDim, + height: 1.35, + ), + ), + ], + ), + ); + } + + Widget _targetHeader() { + final jp = context.jp; + final kindColor = _isWeb ? PeachTheme.accent : PeachTheme.seed; + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + decoration: BoxDecoration( + color: jp.surface, + border: Border(bottom: BorderSide(color: jp.border)), + ), + child: Wrap( + spacing: 18, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _kindChip(kindColor), + _headerItem( + Icons.dns, + '${_server.username}@${_server.host}:${_server.port}', + ), + _headerItem( + _isWeb ? Icons.language : Icons.folder_zip, + _resolvedTarget, + ), + if (_isWeb) + _headerItem( + Icons.dns_outlined, + 'nginx: ${_service.nginxService.isEmpty ? "nginx" : _service.nginxService}', + ) + else if (_service.serviceName.isNotEmpty) + _headerItem( + Icons.settings_suggest, + 'unit: ${_service.serviceName}', + ), + _headerItem( + _server.useSudo ? Icons.shield : Icons.shield_outlined, + _server.useSudo ? 'sudo' : 'no-sudo', + ), + ], + ), + ); + } + + Widget _kindChip(Color color) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _isWeb + ? Icons.language + : (_isGeneric ? Icons.terminal : Icons.coffee), + size: 13, + color: color, + ), + const SizedBox(width: 5), + text( + _isWeb + ? _service.framework.label + : (_isGeneric ? 'Service' : 'Jar service'), + 11.5, + TextType.Bold, + color: color, + ), + ], + ), + ); + + Widget _headerItem(IconData icon, String label) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: context.jp.textDim), + const SizedBox(width: 6), + text(label, 12.5, TextType.Regular, color: context.jp.textDim), + ], + ); + + Widget _sourceChooser(ViewDeploy model) { + return LayoutBuilder( + builder: (context, c) { + final dropCard = _dropCard(); + final buildCard = _buildCard(model); + if (c.maxWidth <= 680) { + return Column( + children: [ + dropCard, + const SizedBox(height: 10), + _orBadge(), + const SizedBox(height: 10), + buildCard, + ], + ); + } + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: dropCard), + const SizedBox(width: 14), + _orBadge(), + const SizedBox(width: 14), + Expanded(child: buildCard), + ], + ), + ); + }, + ); + } + + Widget _orBadge() => Center( + child: Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: context.jp.surface2, + shape: BoxShape.circle, + border: Border.all(color: context.jp.border), + ), + child: text('OR', 10, TextType.Bold, color: context.jp.textDim), + ), + ); + + Widget _optionShell({ + required String tag, + required IconData icon, + required String title, + required Widget child, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: PeachTheme.panel(context, radius: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(7), + decoration: BoxDecoration( + color: PeachTheme.seed.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(9), + ), + child: Icon(icon, size: 18, color: PeachTheme.seed), + ), + const SizedBox(width: 10), + text(title, 15, TextType.Bold, color: context.jp.text), + const Spacer(), + text( + tag, + 11, + TextType.Bold, + color: context.jp.textFaint, + letterSpacing: 1, + ), + ], + ), + const SizedBox(height: 14), + child, + ], + ), + ); + } + + Widget _dropCard() { + return _optionShell( + tag: 'OPTION 1', + icon: _isWeb ? Icons.drive_folder_upload : Icons.upload_file, + title: _isWeb + ? 'Use a built folder' + : (_isGeneric ? 'Drag a file' : 'Drag a jar'), + child: DropTarget( + onDragEntered: _onDragEntered, + onDragExited: _onDragExited, + onDragDone: _onDragDone, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: _onPickArtifact, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + height: 120, + decoration: BoxDecoration( + gradient: _dragging + ? LinearGradient( + colors: [ + PeachTheme.seed.withValues(alpha: 0.22), + PeachTheme.seed.withValues(alpha: 0.06), + ], + ) + : null, + color: _dragging ? null : context.jp.surface2, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: _dragging ? PeachTheme.seed : context.jp.border, + width: _dragging ? 2 : 1, + ), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _isWeb ? Icons.folder_special : Icons.upload_file, + size: 32, + color: PeachTheme.seed.withValues(alpha: 0.85), + ), + const SizedBox(height: 8), + text( + _isWeb + ? 'Drop the built site folder, or click to choose' + : (_isGeneric + ? 'Drop any file here, or click to browse' + : 'Drop a .jar here, or click to browse'), + 14, + TextType.Regular, + color: context.jp.text, + align: TextAlign.center, + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildCard(ViewDeploy model) { + final supported = model.buildSupported; + final hasPath = _projectPath.trim().isNotEmpty; + final building = _phase == DeployPhase.building; + return _optionShell( + tag: 'OPTION 2', + icon: Icons.build, + title: 'Build from source', + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: context.jp.surface2, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: context.jp.border), + ), + child: Row( + children: [ + Icon( + Icons.folder, + size: 18, + color: hasPath ? PeachTheme.accent : context.jp.textFaint, + ), + const SizedBox(width: 8), + Expanded( + child: text( + hasPath ? _projectPath : 'No project path set', + 12.5, + TextType.Regular, + color: hasPath ? context.jp.textDim : context.jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: supported ? _onPickProjectDir : null, + child: text( + hasPath ? 'Change' : 'Choose', + 13, + TextType.Bold, + color: colorPrimary, + ), + ), + ], + ), + ), + const SizedBox(height: 6), + text( + supported + ? (_isWeb + ? 'Builds ${_service.framework.label}, copies the output, ' + 'then asks you to deploy. Path is saved.' + : 'Auto-detects Maven / Gradle, builds, then asks you to ' + 'deploy. Path is saved.') + : 'Local builds run on desktop only.', + 12, + TextType.Regular, + color: context.jp.textFaint, + ), + const SizedBox(height: 12), + if (building) + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _spinner(), + const SizedBox(width: 8), + text('Building…', 14, TextType.Bold, color: colorWhite), + ], + ), + ), + ), + const SizedBox(width: 10), + _cancelButton(), + ], + ) + else + FilledButton.icon( + onPressed: !supported || !hasPath ? null : _onBuild, + icon: const Icon(Icons.build), + label: text('Run build', 14, TextType.Bold, color: colorWhite), + ), + ], + ), + ); + } + + Widget _readyPanel() { + final ok = _succeeded && _phase == DeployPhase.done; + final accent = PeachTheme.seed; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.jp.surface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: accent.withValues(alpha: ok ? 0.7 : 0.5)), + boxShadow: [ + ...PeachTheme.softShadow(Theme.of(context).brightness), + ...PeachTheme.glow(accent, opacity: ok ? 0.26 : 0.14, blur: 30), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + ok + ? Icons.check_circle + : (_isWeb ? Icons.language : Icons.inventory_2), + color: accent, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: text( + _artifactName, + 15, + TextType.Bold, + color: context.jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (ok) ...[const SizedBox(width: 8), _deployedBadge()], + ], + ), + const SizedBox(height: 2), + text( + '${ok ? "Deployed" : (_source.isEmpty ? "Ready" : _source)}' + '${_jarBytes != null ? " Β· ${formatBytes(_jarBytes!.length)}" : ""}' + ' β†’ $_resolvedTarget', + 12, + TextType.Regular, + color: context.jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + TextButton.icon( + onPressed: _busy ? null : _onClearStaged, + icon: Icon(ok ? Icons.add : Icons.close, size: 16), + label: text( + ok ? 'New' : 'Change', + 13, + TextType.Bold, + color: colorPrimary, + ), + ), + ], + ), + const SizedBox(height: 14), + if (_busy) + _busyActions() + else if (ok) + _successActions() + else + _stagedActions(), + ], + ), + ); + } + + Widget _deployedBadge() => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: PeachTheme.seed.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.check, size: 11, color: PeachTheme.seed), + const SizedBox(width: 3), + text( + 'DEPLOYED', + 10, + TextType.Bold, + color: PeachTheme.seed, + letterSpacing: 0.6, + ), + ], + ), + ); + + Widget _busyActions() { + final label = switch (_phase) { + DeployPhase.packaging => 'Packaging…', + DeployPhase.deploying => 'Deploying…', + _ => 'Reverting…', + }; + return Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: null, + icon: _spinner(), + label: text(label, 14, TextType.Bold, color: colorWhite), + ), + ), + const SizedBox(width: 10), + _cancelButton(), + ], + ); + } + + Widget _successActions() { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _onDeploy, + icon: const Icon(Icons.refresh, size: 18), + label: text( + _isWeb ? 'Redeploy site' : 'Redeploy', + 14, + TextType.Bold, + color: context.jp.textDim, + ), + style: OutlinedButton.styleFrom( + foregroundColor: context.jp.textDim, + side: BorderSide(color: context.jp.border), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ), + const SizedBox(width: 10), + _tailButton(), + ], + ); + } + + Widget _stagedActions() { + return Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _onDeploy, + icon: const Icon(Icons.rocket_launch), + label: text( + _isWeb + ? 'Proceed to publish to nginx' + : 'Proceed to deploy & restart', + 14, + TextType.Bold, + color: colorWhite, + ), + ), + ), + const SizedBox(width: 10), + _tailButton(), + ], + ); + } + + Widget _tailButton() => OutlinedButton.icon( + onPressed: _onToggleFollow, + icon: Icon(_following ? Icons.stop_circle : Icons.stream, size: 18), + label: text( + _following ? 'Stop tail' : 'Live tail', + 13, + TextType.Bold, + color: _following ? PeachTheme.seed : context.jp.textDim, + ), + style: OutlinedButton.styleFrom( + foregroundColor: _following ? PeachTheme.seed : context.jp.textDim, + side: BorderSide(color: _following ? PeachTheme.seed : context.jp.border), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + + Widget _cancelButton() => OutlinedButton.icon( + onPressed: _onCancel, + icon: const Icon(Icons.cancel, size: 18), + label: text('Cancel', 13, TextType.Bold, color: PeachTheme.danger), + style: OutlinedButton.styleFrom( + foregroundColor: PeachTheme.danger, + side: BorderSide(color: PeachTheme.danger.withValues(alpha: 0.7)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + + Widget _resultBanner(DeployResult result) { + final color = result.ok ? PeachTheme.seed : PeachTheme.danger; + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(result.ok ? Icons.check_circle : Icons.error, color: color), + const SizedBox(width: 10), + Expanded( + child: text(result.message, 14, TextType.Regular, color: color), + ), + if (result.canRevert && _phase != DeployPhase.reverting) ...[ + TextButton( + onPressed: _onDiscardBackup, + child: text( + 'Keep new', + 13, + TextType.Bold, + color: context.jp.textDim, + ), + ), + const SizedBox(width: 4), + FilledButton.icon( + onPressed: _onRevert, + icon: const Icon(Icons.undo, size: 16), + label: text('Revert', 13, TextType.Bold, color: colorWhite), + style: FilledButton.styleFrom( + backgroundColor: color, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + ], + if (_phase == DeployPhase.reverting) + Padding( + padding: const EdgeInsets.only(right: 8), + child: _spinner(), + ), + ], + ), + ); + } + + Widget _spinner() => SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: colorWhite), + ); +} diff --git a/lib/Peach/see/deploy/single/ViewDeploy.dart b/lib/Peach/see/deploy/single/ViewDeploy.dart new file mode 100644 index 0000000..d89de45 --- /dev/null +++ b/lib/Peach/see/deploy/single/ViewDeploy.dart @@ -0,0 +1,404 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../about/internal/application/DeploymentRecord.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/ServiceConfig.dart'; +import '../../../about/internal/application/StepTracker.dart'; +import '../../../comms/Comms.dart' show DeployResult, LogLine, TransferProgress; +import '../../../utils/CommonUtils.dart'; +import '../../parent/ParentViewModel.dart'; +import 'ConnectDeploy.dart'; + +class ViewDeploy extends ParentViewModel { + ConnectDeploy connection; + + ViewDeploy(super.context, this.connection); + + /// Both the SSH session and the local build write to the same log stream on + /// screen, so the console reads as one continuous story. + Stream get logs => getDataManager().logs; + + Stream get buildLogs => getDataManager().buildLogs; + + Stream> get steps => getDataManager().stepStream; + + Stream> get buildSteps => getDataManager().buildStepStream; + + Stream get transferProgress => + getDataManager().transferProgress; + + bool get isConnected => getDataManager().isConnected; + + bool get canRevert => getDataManager().canRevert; + + bool get buildSupported => getDataManager().buildSupported; + + // ── staging ──────────────────────────────────────────────────────────────── + + /// Reads where nginx actually serves from, so a mismatched web root can be + /// flagged before the user waits through a deploy that won't show up. + void loadNginxRoots(ServerProfile server) async { + try { + final roots = await getDataManager().nginxRoots(server); + connection.onNginxRootsLoaded(roots); + } catch (_) { + connection.onNginxRootsLoaded(const []); + } + } + + /// Picks a jar (or any file, for a generic service). + void pickArtifact(bool anyFile) async { + try { + final picked = anyFile + ? await FilePicker.pickFiles(withData: true) + : await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['jar'], + withData: true, + ); + if (picked == null || picked.files.isEmpty) return; + final file = picked.files.first; + final bytes = file.bytes ?? await File(file.path!).readAsBytes(); + connection.onJarStaged(bytes, file.name, 'Dropped file'); + } catch (error) { + handleError(error, () => pickArtifact(anyFile), () {}, "Retry"); + } + } + + /// Picks the folder holding a built site. + void pickWebFolder() async { + try { + final directory = await FilePicker.getDirectoryPath( + dialogTitle: 'Select the built site folder (contains index.html)', + ); + if (directory == null) return; + connection.onWebStaged(directory, 'Selected folder'); + } catch (error) { + handleError(error, pickWebFolder, () {}, "Retry"); + } + } + + /// Accepts a drag-and-drop payload, rejecting anything the service can't use. + void stageDropped(DropDoneDetails details, bool isWeb, bool isGeneric) async { + if (details.files.isEmpty) return; + try { + if (isWeb) { + final entity = details.files.first; + if (FileSystemEntity.isDirectorySync(entity.path)) { + connection.onWebStaged(entity.path, 'Dropped folder'); + } else { + connection.onBuildFailed('Drop the built site folder here.'); + } + return; + } + + if (isGeneric) { + final file = details.files.first; + if (FileSystemEntity.isDirectorySync(file.path)) { + connection.onBuildFailed('Drop a single file, not a folder.'); + return; + } + connection.onJarStaged( + await file.readAsBytes(), + file.name, + 'Dropped file', + ); + return; + } + + final jar = details.files.firstWhere( + (f) => f.name.toLowerCase().endsWith('.jar'), + orElse: () => details.files.first, + ); + if (!jar.name.toLowerCase().endsWith('.jar')) { + connection.onBuildFailed('That is not a .jar file.'); + return; + } + connection.onJarStaged(await jar.readAsBytes(), jar.name, 'Dropped file'); + } catch (error) { + handleError(error, () {}, () {}, "Okay"); + } + } + + /// Picks the source project folder and remembers it on the service. + void pickProjectDirectory(ServerProfile server, ServiceConfig service) async { + try { + final directory = await FilePicker.getDirectoryPath( + dialogTitle: 'Select the service project folder', + ); + if (directory == null) return; + await saveProjectPath(server, service, directory); + } catch (error) { + handleError( + error, + () => pickProjectDirectory(server, service), + () {}, + "Retry", + ); + } + } + + /// Persists the project path so the next deploy doesn't ask again. + Future saveProjectPath( + ServerProfile server, + ServiceConfig service, + String path, + ) async { + if (service.localProjectPath == path) return; + service.localProjectPath = path; + final servers = await getDataManager().getServers(); + final serverIndex = servers.indexWhere((s) => s.id == server.id); + if (serverIndex < 0) return; + final services = servers[serverIndex].services; + final serviceIndex = services.indexWhere((s) => s.id == service.id); + if (serviceIndex < 0) return; + services[serviceIndex] = service; + await getDataManager().setServers(servers); + } + + // ── build ────────────────────────────────────────────────────────────────── + + /// Compiles the project and stages whatever it produces. + void buildFromSource(ServiceConfig service, bool isWeb) async { + try { + final result = isWeb + ? await getDataManager().buildWeb(service) + : await getDataManager().build(service); + + if (!result.ok) { + connection.onBuildFailed(result.message); + return; + } + + if (isWeb) { + connection.onWebStaged(result.dir!.path, 'Built from source'); + } else { + final jar = result.jar!; + connection.onJarStaged( + await jar.readAsBytes(), + _basename(jar.path), + 'Built from source', + ); + } + } catch (error) { + connection.onBuildFailed(error.toString()); + } + } + + // ── deploy ───────────────────────────────────────────────────────────────── + + /// Ships a staged jar/binary, then records the outcome in the history. + void deployJar( + ServerProfile server, + ServiceConfig service, + Uint8List bytes, + String label, + ) async { + if (!await canReach( + server, + () => deployJar(server, service, bytes, label), + )) { + return; + } + DeployResult result; + try { + result = await getDataManager().deploy( + server: server, + service: service, + jarBytes: bytes, + jarLabel: label, + ); + } catch (error) { + result = DeployResult(false, canRevert, 'Error: $error'); + } + await _record( + server, + service, + result, + label, + service.remotePath(label.isEmpty ? '' : label), + ); + connection.onDeployFinished(result); + } + + /// Packages the built site, then publishes it to nginx. + void deployWeb( + ServerProfile server, + ServiceConfig service, + String directory, + ) async { + if (!await canReach(server, () => deployWeb(server, service, directory))) { + return; + } + + // The archive is extracted at the web root, so index.html has to be at the + // folder's top level β€” for Angular that's dist//browser, which is + // an easy directory to miss. + if (!File('$directory/index.html').existsSync()) { + connection.onDeployFinished( + DeployResult( + false, + false, + 'No index.html in "$directory". Point at the folder that CONTAINS ' + 'index.html (for Angular that\'s dist//browser).', + ), + ); + return; + } + + DeployResult result; + try { + connection.onPackagingStarted(); + final (bytes, count) = await getDataManager().packageWeb(directory); + if (count == 0) { + connection.onDeployFinished( + DeployResult( + false, + false, + 'That folder is empty β€” nothing to deploy.', + ), + ); + return; + } + result = await getDataManager().deployWeb( + server: server, + service: service, + archiveBytes: bytes, + fileCount: count, + ); + } catch (error) { + result = DeployResult(false, canRevert, 'Error: $error'); + } + await _record( + server, + service, + result, + _basename(directory), + service.remoteRoot, + ); + connection.onDeployFinished(result); + } + + /// Restores the backup the last deploy left behind. + void revert(ServerProfile server, ServiceConfig service) async { + try { + final result = await getDataManager().revert(); + if (result.ok) { + await _writeRecord( + server, + service, + DeployStatus.reverted, + '(previous)', + result.message, + service.remoteRoot, + ); + } + connection.onRevertFinished(result); + } catch (error) { + connection.onRevertFinished(DeployResult(false, false, '$error')); + } + } + + /// Drops the backup, committing to the version just deployed. + void discardBackup() async { + try { + await getDataManager().discardBackup(); + connection.onBackupDiscarded(); + } catch (error) { + handleError(error, discardBackup, () {}, "Retry"); + } + } + + /// Aborts whichever half of the pipeline is running. + void cancel(bool building) { + if (building) { + getDataManager().cancelBuild(); + } else { + getDataManager().requestCancel(); + } + } + + void disconnect() { + getDataManager().disconnect(); + connection.onFollowChanged(false); + } + + void toggleFollow( + ServerProfile server, + ServiceConfig service, + bool currentlyFollowing, + ) async { + if (currentlyFollowing) { + getDataManager().stopLogs(); + connection.onFollowChanged(false); + return; + } + connection.onFollowChanged(true); + try { + await getDataManager().followLogs(server, service); + } catch (error) { + connection.onFollowChanged(false); + handleError( + error, + () => toggleFollow(server, service, false), + () {}, + "Retry", + ); + } + } + + // ── history ──────────────────────────────────────────────────────────────── + + Future _record( + ServerProfile server, + ServiceConfig service, + DeployResult result, + String artifact, + String target, + ) { + return _writeRecord( + server, + service, + result.ok ? DeployStatus.success : DeployStatus.failure, + artifact, + result.message, + target, + ); + } + + Future _writeRecord( + ServerProfile server, + ServiceConfig service, + DeployStatus status, + String artifact, + String message, + String target, + ) async { + try { + await getDataManager().addDeployment( + DeploymentRecord( + id: const Uuid().v4(), + serverId: server.id, + serverName: server.name, + serviceId: service.id, + serviceLabel: service.name, + jarName: artifact, + remotePath: target, + time: DateTime.now(), + status: status, + message: message, + ), + ); + } catch (_) { + // Losing a history row must never mask the deploy's own outcome, which + // the caller is about to report. + } + } + + String _basename(String path) => baseName(path); +} diff --git a/lib/Peach/see/docker/manager/ConnectDockerManager.dart b/lib/Peach/see/docker/manager/ConnectDockerManager.dart new file mode 100644 index 0000000..a445fc2 --- /dev/null +++ b/lib/Peach/see/docker/manager/ConnectDockerManager.dart @@ -0,0 +1,22 @@ +import '../../../about/external/data/Docker.dart'; + +abstract class ConnectDockerManager { + /// Docker's state on the server, plus its containers when it's running. + void onDockerLoaded(DockerStatus status, List containers); + + /// A container action (start/stop/restart/rm) finished. + void onActionFinished(String output); + + /// Output to show in a sheet β€” container logs, or a test run. + void onOutputReady(String title, String output); + + /// The install command was resolved for this distro; the screen confirms it + /// with the user before anything runs. + void onInstallCommandReady(String command); + + /// The Docker install finished; the caller should re-check status. + void onInstallFinished(); + + /// Something failed with a message worth surfacing inline. + void onDockerFailed(String message); +} diff --git a/lib/Peach/see/docker/manager/DockerManager.dart b/lib/Peach/see/docker/manager/DockerManager.dart new file mode 100644 index 0000000..09a8f4c --- /dev/null +++ b/lib/Peach/see/docker/manager/DockerManager.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import 'DockerManagerState.dart'; + +/// See, start/stop/restart, inspect logs and remove Docker containers β€” and +/// install Docker if it's missing. +class DockerManager extends StatefulWidget { + final ServerProfile server; + + const DockerManager({super.key, required this.server}); + + @override + State createState() => DockerManagerState(); +} diff --git a/lib/Peach/see/docker/manager/DockerManagerState.dart b/lib/Peach/see/docker/manager/DockerManagerState.dart new file mode 100644 index 0000000..96294f4 --- /dev/null +++ b/lib/Peach/see/docker/manager/DockerManagerState.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../about/external/data/Docker.dart'; +import '../../../about/internal/application/NotificationType.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/TextType.dart'; +import '../../../configs/Navigator.dart'; +import '../../../designs/OutputSheet.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import '../run/DockerRun.dart'; +import 'ConnectDockerManager.dart'; +import 'DockerManager.dart'; +import 'ViewDockerManager.dart'; + +class DockerManagerState extends State + implements ConnectDockerManager { + ViewDockerManager? _model; + + DockerStatus? _status; + List _containers = []; + bool _loading = true; + bool _installing = false; + String? _busyId; + + ServerProfile get _server => widget.server; + + bool get _ready => _status != null && _status!.installed && _status!.running; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewDockerManager(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + 'Docker Β· ${_server.name}', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + if (_ready) + IconButton( + tooltip: 'Test (hello-world)', + onPressed: _onTest, + icon: const Icon(Icons.verified), + ), + IconButton( + tooltip: 'Refresh', + onPressed: _loading ? null : _onRefresh, + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 4), + ], + ), + floatingActionButton: _ready + ? FloatingActionButton.extended( + onPressed: _onOpenRun, + icon: const Icon(Icons.add), + label: text( + 'Run container', + 14, + TextType.Bold, + color: colorWhite, + ), + ) + : null, + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + _statusCard(), + if (_ready) ...[ + const SizedBox(height: 16), + _sectionHeader(), + const SizedBox(height: 10), + if (_loading) + const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: CircularProgressIndicator(), + ), + ) + else if (_containers.isEmpty) + _empty() + else + LayoutBuilder( + builder: (context, c) { + const gap = 12.0; + final cols = c.maxWidth >= 720 ? 2 : 1; + final w = (c.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final container in _containers) + SizedBox(width: w, child: _containerCard(container)), + ], + ); + }, + ), + ], + ], + ), + ), + ); + } + + void _onReady(ViewDockerManager model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _model?.loadDocker(_server); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onRefresh() { + setState(() => _loading = true); + _model?.loadDocker(_server); + } + + void _onTest() => _model?.runTest(_server); + + void _onInstall() => _model?.prepareInstall(_server); + + Future _onOpenRun() async { + await PeachNavigation().navigateToPageWithData( + DockerRun(server: _server), + context, + ); + _onRefresh(); + } + + void _onStart(DockerContainer c) => _action(c, 'start'); + + void _onStop(DockerContainer c) => _action(c, 'stop'); + + void _onRestart(DockerContainer c) => _action(c, 'restart'); + + void _onRemove(DockerContainer c) { + _model?.showApplicationNotification( + NotificationType.warning, + 'Remove "${c.name}"?', + 'This force-removes the container (docker rm -f).', + true, + true, + null, + action: 'Remove', + positiveAction: () => _onConfirmRemove(c), + ); + } + + void _onConfirmRemove(DockerContainer c) { + Navigator.pop(context); + _action(c, 'rm'); + } + + void _action(DockerContainer c, String action) { + setState(() => _busyId = c.id); + _model?.runAction(_server, c.id, action); + } + + void _onShowLogs(DockerContainer c) { + setState(() => _busyId = c.id); + _model?.loadContainerLogs(_server, c.id, c.name); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectDockerManager ─────────────────────────────────────────────────── + + @override + void onDockerLoaded(DockerStatus status, List containers) { + if (!mounted) return; + setState(() { + _status = status; + _containers = containers; + _loading = false; + _busyId = null; + }); + } + + @override + void onActionFinished(String output) { + if (!mounted) return; + setState(() => _busyId = null); + if (output.toLowerCase().contains('error')) _notify(output); + _onRefresh(); + } + + @override + void onOutputReady(String title, String output) { + if (!mounted) return; + setState(() => _busyId = null); + showOutputSheet(context, title, output); + } + + @override + void onInstallCommandReady(String command) { + _model?.showApplicationNotification( + NotificationType.info, + 'Install Docker?', + 'Runs:\n$command\nthen: systemctl enable --now docker', + true, + true, + null, + action: 'Install', + positiveAction: () => _onConfirmInstall(command), + ); + } + + void _onConfirmInstall(String command) { + Navigator.pop(context); + setState(() => _installing = true); + _model?.installDocker(_server, command); + } + + @override + void onInstallFinished() { + if (!mounted) return; + setState(() => _installing = false); + _onRefresh(); + } + + @override + void onDockerFailed(String message) { + if (!mounted) return; + setState(() { + _loading = false; + _busyId = null; + }); + _notify(message); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _sectionHeader() => Row( + children: [ + text( + 'CONTAINERS', + 12, + TextType.Bold, + color: context.jp.textDim, + letterSpacing: 1.2, + ), + const SizedBox(width: 8), + text('${_containers.length}', 12, TextType.Bold, color: PeachTheme.seed), + ], + ); + + Widget _statusCard() { + final jp = context.jp; + final installed = _status?.installed ?? false; + final running = _status?.running ?? false; + final (color, label) = !installed + ? (jp.textFaint, 'NOT INSTALLED') + : running + ? (PeachTheme.seed, 'RUNNING') + : (PeachTheme.warn, 'STOPPED'); + return Container( + padding: const EdgeInsets.all(16), + decoration: PeachTheme.panel(context, glowColor: PeachTheme.accent), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: PeachTheme.accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(13), + ), + child: const Icon(Icons.inventory_2, color: PeachTheme.accent), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + _loading ? 'Checking Docker…' : 'Docker', + 16, + TextType.Bold, + color: jp.text, + ), + text( + installed ? (_status?.version ?? '') : 'Not installed', + 12, + TextType.Regular, + color: jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: text(label, 10.5, TextType.Bold, color: color), + ), + ], + ), + if (!installed) ...[ + const SizedBox(height: 14), + FilledButton.icon( + onPressed: _installing ? null : _onInstall, + icon: _installing + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorWhite, + ), + ) + : const Icon(Icons.download), + label: text( + _installing ? 'Installing…' : 'Install Docker', + 14, + TextType.Bold, + color: colorWhite, + ), + ), + ] else if (!running) ...[ + const SizedBox(height: 12), + text( + 'The Docker daemon is not running. Start it with ' + 'systemctl start docker (Configuration β†’ install also enables it).', + 12.5, + TextType.Regular, + color: jp.textDim, + ), + ], + ], + ), + ); + } + + Widget _empty() { + final jp = context.jp; + return Container( + padding: const EdgeInsets.symmetric(vertical: 40), + alignment: Alignment.center, + decoration: PeachTheme.panel(context), + child: Column( + children: [ + Icon(Icons.inbox, size: 40, color: jp.textFaint), + const SizedBox(height: 10), + text('No containers yet', 14, TextType.Regular, color: jp.textDim), + const SizedBox(height: 4), + text( + 'Tap "Run container" to start one.', + 12, + TextType.Light, + color: jp.textFaint, + ), + ], + ), + ); + } + + Widget _containerCard(DockerContainer c) { + final jp = context.jp; + final busy = _busyId == c.id; + final (color, label) = switch (c.state.toLowerCase()) { + 'running' => (PeachTheme.seed, 'UP'), + 'exited' => (jp.textFaint, 'EXITED'), + 'paused' => (PeachTheme.warn, 'PAUSED'), + 'created' => (PeachTheme.accent, 'CREATED'), + 'restarting' => (PeachTheme.warn, 'RESTARTING'), + _ => (PeachTheme.danger, c.state.toUpperCase()), + }; + return Container( + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.widgets, + size: 18, + color: c.up ? PeachTheme.seed : jp.textFaint, + ), + const SizedBox(width: 8), + Expanded( + child: text( + c.name, + 14, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + ), + child: text(label, 10, TextType.Bold, color: color), + ), + ], + ), + const SizedBox(height: 6), + text( + c.image, + 12, + TextType.Regular, + color: jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + text( + c.status, + 11.5, + TextType.Light, + color: jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (c.ports.trim().isNotEmpty) + text( + c.ports, + 11.5, + TextType.Light, + color: jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Row( + children: [ + if (busy) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else ...[ + if (c.up) ...[ + _act(Icons.restart_alt, 'Restart', () => _onRestart(c)), + _act(Icons.stop, 'Stop', () => _onStop(c)), + ] else + _act(Icons.play_arrow, 'Start', () => _onStart(c)), + _act(Icons.article_outlined, 'Logs', () => _onShowLogs(c)), + _act( + Icons.delete_outline, + 'Remove', + () => _onRemove(c), + color: PeachTheme.danger, + ), + ], + ], + ), + ], + ), + ); + } + + Widget _act(IconData icon, String tip, VoidCallback onTap, {Color? color}) => + IconButton( + tooltip: tip, + iconSize: 20, + color: color ?? context.jp.textDim, + onPressed: onTap, + icon: Icon(icon), + ); +} diff --git a/lib/Peach/see/docker/manager/ViewDockerManager.dart b/lib/Peach/see/docker/manager/ViewDockerManager.dart new file mode 100644 index 0000000..d1e8586 --- /dev/null +++ b/lib/Peach/see/docker/manager/ViewDockerManager.dart @@ -0,0 +1,117 @@ +import '../../../about/external/data/Docker.dart'; +import '../../../about/external/data/EnvCheck.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../parent/ParentViewModel.dart'; +import 'ConnectDockerManager.dart'; + +class ViewDockerManager extends ParentViewModel { + ConnectDockerManager connection; + + ViewDockerManager(super.context, this.connection); + + /// Reads Docker's state and, when the daemon is up, its containers. + void loadDocker(ServerProfile server) async { + if (!await canReach(server, () => loadDocker(server))) return; + try { + final status = await getDataManager().dockerStatus(server); + var containers = []; + if (status.installed && status.running) { + containers = await getDataManager().dockerPs(server, all: true); + } + connection.onDockerLoaded(status, containers); + } catch (error) { + connection.onDockerFailed(error.toString()); + } + } + + /// start | stop | restart | rm on one container. + void runAction( + ServerProfile server, + String containerId, + String action, + ) async { + try { + final output = await getDataManager().dockerAction( + server, + containerId, + action, + ); + connection.onActionFinished(output); + } catch (error) { + handleError( + error, + () => runAction(server, containerId, action), + () {}, + "Retry", + ); + } + } + + void loadContainerLogs( + ServerProfile server, + String containerId, + String name, + ) async { + try { + final output = await getDataManager().dockerLogs(server, containerId); + connection.onOutputReady( + '$name Β· logs', + output.isEmpty ? '(no output)' : output, + ); + } catch (error) { + handleError( + error, + () => loadContainerLogs(server, containerId, name), + () {}, + "Retry", + ); + } + } + + /// Runs `hello-world` to prove the daemon can pull and execute an image. + void runTest(ServerProfile server) async { + showLoading("Running hello-world …"); + try { + final output = await getDataManager().dockerTest(server); + closeLoading(); + connection.onOutputReady('docker run --rm hello-world', output); + } catch (error) { + handleError(error, () => runTest(server), () {}, "Retry"); + } + } + + /// Resolves the right package-manager command for this distro, then hands + /// it back so the screen can confirm before anything is installed. + void prepareInstall(ServerProfile server) async { + if (!await canReach(server, () => prepareInstall(server))) return; + try { + final manager = await getDataManager().detectPackageManager(server); + if (manager == 'unknown') { + connection.onDockerFailed( + 'Unknown package manager β€” install Docker manually.', + ); + return; + } + final tool = kEnvTools.firstWhere((t) => t.key == 'docker'); + connection.onInstallCommandReady( + installCommandFor(manager, tool.packagesFor(manager)), + ); + } catch (error) { + handleError(error, () => prepareInstall(server), () {}, "Retry"); + } + } + + void installDocker(ServerProfile server, String command) async { + try { + await getDataManager().installPackages( + server: server, + command: command, + enableUnit: 'docker', + ); + connection.onInstallFinished(); + } catch (error) { + connection.onInstallFinished(); + handleError(error, () => installDocker(server, command), () {}, "Retry"); + } + } +} diff --git a/lib/Peach/see/docker/run/ConnectDockerRun.dart b/lib/Peach/see/docker/run/ConnectDockerRun.dart new file mode 100644 index 0000000..27b9d12 --- /dev/null +++ b/lib/Peach/see/docker/run/ConnectDockerRun.dart @@ -0,0 +1,5 @@ +abstract class ConnectDockerRun { + /// The run finished: [started] is whether the container came up, and + /// [output] is the container id on success or the failure text. + void onRunFinished(bool started, String output); +} diff --git a/lib/Peach/see/docker/run/DockerRun.dart b/lib/Peach/see/docker/run/DockerRun.dart new file mode 100644 index 0000000..f64774b --- /dev/null +++ b/lib/Peach/see/docker/run/DockerRun.dart @@ -0,0 +1,14 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import 'DockerRunState.dart'; + +/// Form to `docker run` a new container. +class DockerRun extends StatefulWidget { + final ServerProfile server; + + const DockerRun({super.key, required this.server}); + + @override + State createState() => DockerRunState(); +} diff --git a/lib/Peach/see/docker/run/DockerRunState.dart b/lib/Peach/see/docker/run/DockerRunState.dart new file mode 100644 index 0000000..5e5e6e6 --- /dev/null +++ b/lib/Peach/see/docker/run/DockerRunState.dart @@ -0,0 +1,313 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../about/external/data/Docker.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/TextType.dart'; +import '../../../comms/Comms.dart' show LogLine; +import '../../../designs/LogConsole.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import 'ConnectDockerRun.dart'; +import 'DockerRun.dart'; +import 'ViewDockerRun.dart'; + +class DockerRunState extends State implements ConnectDockerRun { + ViewDockerRun? _model; + + StreamSubscription? _logSub; + final _lines = []; + + final _image = TextEditingController(); + final _name = TextEditingController(); + final _ports = TextEditingController(); + final _env = TextEditingController(); + final _volumes = TextEditingController(); + final _command = TextEditingController(); + + String _restart = 'unless-stopped'; + bool _detached = true; + bool _running = false; + (bool, String)? _result; + + ServerProfile get _server => widget.server; + + @override + void dispose() { + _logSub?.cancel(); + for (final c in [_image, _name, _ports, _env, _volumes, _command]) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewDockerRun(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + 'Run container', + 20, + TextType.Bold, + color: context.jp.text, + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 40), + children: [ + _field( + 'Image', + _image, + hint: 'nginx:latest Β· redis:7 Β· postgres:16', + ), + _field('Container name (optional)', _name, hint: 'my-nginx'), + _field( + 'Ports (host:container, one per line)', + _ports, + hint: '8080:80\n443:443', + lines: 2, + mono: true, + ), + _field( + 'Environment (KEY=VALUE, one per line)', + _env, + hint: 'POSTGRES_PASSWORD=secret', + lines: 2, + mono: true, + ), + _field( + 'Volumes (host:container, one per line)', + _volumes, + hint: '/data/pg:/var/lib/postgresql/data', + lines: 2, + mono: true, + ), + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: _restart, + decoration: const InputDecoration( + labelText: 'Restart policy', + ), + items: const [ + DropdownMenuItem(value: 'no', child: Text('no')), + DropdownMenuItem( + value: 'on-failure', + child: Text('on-failure'), + ), + DropdownMenuItem(value: 'always', child: Text('always')), + DropdownMenuItem( + value: 'unless-stopped', + child: Text('unless-stopped'), + ), + ], + onChanged: _onRestartChanged, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + value: _detached, + onChanged: _onDetachedChanged, + title: text( + 'Detached (-d)', + 14, + TextType.Regular, + color: context.jp.text, + ), + ), + ), + ], + ), + _field( + 'Command / args (optional)', + _command, + hint: 'redis-server --appendonly yes', + mono: true, + ), + const SizedBox(height: 8), + FilledButton.icon( + onPressed: _running ? null : _onRun, + icon: _running + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorWhite, + ), + ) + : const Icon(Icons.play_arrow), + label: text( + _running ? 'Starting…' : 'Run container', + 14, + TextType.Bold, + color: colorWhite, + ), + ), + if (_result != null) ...[ + const SizedBox(height: 14), + _banner(_result!), + ], + if (_lines.isNotEmpty) ...[ + const SizedBox(height: 14), + LogConsole( + logs: _lines, + height: 220, + onClear: _onClearLogs, + onCopy: _onCopyLogs, + ), + ], + ], + ), + ), + ); + } + + void _onReady(ViewDockerRun model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _logSub = _model?.logs.listen(_onLogLine); + } + + void _onLogLine(LogLine line) { + if (!mounted) return; + setState(() => _lines.add(line)); + } + + DockerRunSpec _spec() => DockerRunSpec( + image: _image.text, + name: _name.text, + ports: _ports.text, + env: _env.text, + volumes: _volumes.text, + restart: _restart, + detached: _detached, + command: _command.text, + ); + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onRun() { + if (_image.text.trim().isEmpty) { + _notify('Enter an image (e.g. nginx:latest).'); + return; + } + setState(() { + _running = true; + _lines.clear(); + _result = null; + }); + _model?.runContainer(_server, _spec()); + } + + void _onRestartChanged(String? v) => setState(() => _restart = v ?? 'no'); + + void _onDetachedChanged(bool v) => setState(() => _detached = v); + + void _onClearLogs() => setState(() => _lines.clear()); + + void _onCopyLogs() { + Clipboard.setData( + ClipboardData(text: _lines.map((l) => l.text).join('\n')), + ); + _notify('Logs copied.'); + } + + void _onViewContainers() => Navigator.pop(context, true); + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectDockerRun ─────────────────────────────────────────────────────── + + @override + void onRunFinished(bool started, String output) { + if (!mounted) return; + setState(() { + _running = false; + _result = (started, output); + }); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _banner((bool, String) result) { + final (ok, output) = result; + final color = ok ? PeachTheme.seed : PeachTheme.danger; + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(ok ? Icons.check_circle : Icons.error, color: color), + const SizedBox(width: 10), + Expanded( + child: text( + ok ? 'Container started βœ“ (${_shortId(output)})' : output, + 14, + TextType.Regular, + color: color, + ), + ), + if (ok) + FilledButton( + onPressed: _onViewContainers, + child: text('View', 13, TextType.Bold, color: colorWhite), + ), + ], + ), + ); + } + + /// `docker run -d` echoes the full container id; the short form is what the + /// CLI shows everywhere else. + String _shortId(String output) { + final id = output.trim().split('\n').last.trim(); + return id.length <= 12 ? id : '${id.substring(0, 12)}…'; + } + + Widget _field( + String label, + TextEditingController controller, { + String? hint, + int lines = 1, + bool mono = false, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: TextField( + controller: controller, + maxLines: lines, + style: mono + ? const TextStyle(fontFamily: 'monospace', fontSize: 13) + : null, + decoration: InputDecoration(labelText: label, hintText: hint), + ), + ); + } +} diff --git a/lib/Peach/see/docker/run/ViewDockerRun.dart b/lib/Peach/see/docker/run/ViewDockerRun.dart new file mode 100644 index 0000000..1607330 --- /dev/null +++ b/lib/Peach/see/docker/run/ViewDockerRun.dart @@ -0,0 +1,25 @@ +import '../../../about/external/data/Docker.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../comms/Comms.dart' show LogLine; +import '../../parent/ParentViewModel.dart'; +import 'ConnectDockerRun.dart'; + +class ViewDockerRun extends ParentViewModel { + ConnectDockerRun connection; + + ViewDockerRun(super.context, this.connection); + + /// The command's output as it runs, shown in the console. + Stream get logs => getDataManager().logs; + + void runContainer(ServerProfile server, DockerRunSpec spec) async { + if (!await canReach(server, () => runContainer(server, spec))) return; + try { + final (started, output) = await getDataManager().dockerRun(server, spec); + connection.onRunFinished(started, output); + } catch (error) { + connection.onRunFinished(false, error.toString()); + handleError(error, () => runContainer(server, spec), () {}, "Retry"); + } + } +} diff --git a/lib/Peach/see/files/ConnectFiles.dart b/lib/Peach/see/files/ConnectFiles.dart new file mode 100644 index 0000000..5e49143 --- /dev/null +++ b/lib/Peach/see/files/ConnectFiles.dart @@ -0,0 +1,18 @@ +import '../../comms/Comms.dart' show RemoteEntry; + +abstract class ConnectFiles { + /// The user's home directory, resolved on open β€” the starting location. + void onHomeResolved(String path); + + /// The contents of the directory currently being shown. + void onDirectoryLoaded(List entries); + + /// The listing failed; [message] explains why. + void onDirectoryFailed(String message); + + /// A transfer finished (or failed) with a message to surface. + void onTransferFinished(String message); + + /// A remote entry was deleted. + void onEntryDeleted(); +} diff --git a/lib/Peach/see/files/Files.dart b/lib/Peach/see/files/Files.dart new file mode 100644 index 0000000..91f68b3 --- /dev/null +++ b/lib/Peach/see/files/Files.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import 'FilesState.dart'; + +/// Browse the server over SFTP: navigate directories, upload local files into +/// the current directory, and download remote files to local disk. +class Files extends StatefulWidget { + final ServerProfile server; + + const Files({super.key, required this.server}); + + @override + State createState() => FilesState(); +} diff --git a/lib/Peach/see/files/FilesState.dart b/lib/Peach/see/files/FilesState.dart new file mode 100644 index 0000000..b69fef5 --- /dev/null +++ b/lib/Peach/see/files/FilesState.dart @@ -0,0 +1,357 @@ +import 'dart:async'; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../comms/Comms.dart' show RemoteEntry, TransferProgress; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import 'ConnectFiles.dart'; +import 'Files.dart'; +import 'ViewFiles.dart'; + +class FilesState extends State implements ConnectFiles { + ViewFiles? _model; + + StreamSubscription? _progressSub; + + String _path = '.'; + List _entries = []; + bool _loading = true; + bool _busy = false; + bool _dragging = false; + String? _error; + double? _progress; + + ServerProfile get _server => widget.server; + + @override + void dispose() { + _progressSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewFiles(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + '${_server.name} Β· files', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: _busy ? null : _onRefresh, + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 4), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _busy ? null : _onUploadPicked, + icon: const Icon(Icons.upload), + label: text('Upload', 14, TextType.Bold, color: colorWhite), + ), + body: DropTarget( + onDragEntered: _onDragEntered, + onDragExited: _onDragExited, + onDragDone: _onDragDone, + child: Column( + children: [ + _pathBar(), + if (_progress != null) + LinearProgressIndicator( + value: _progress == 0 ? null : _progress, + minHeight: 3, + ), + Expanded(child: _body()), + ], + ), + ), + ), + ); + } + + void _onReady(ViewFiles model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _progressSub = _model?.transferProgress.listen(_onProgress); + _model?.resolveHome(_server); + } + + void _onProgress(TransferProgress? p) { + if (!mounted) return; + setState(() => _progress = p?.fraction); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onRefresh() { + setState(() { + _loading = true; + _error = null; + }); + _model?.listDirectory(_server, _path); + } + + void _onEnter(RemoteEntry e) { + if (!e.isDir) return; + setState(() => _path = joinRemote(_path, e.name)); + _onRefresh(); + } + + void _onUp() { + setState(() => _path = parentRemote(_path)); + _onRefresh(); + } + + void _onUploadPicked() { + setState(() { + _busy = true; + _progress = 0; + }); + _model?.uploadPicked(_server, _path, joinRemote); + } + + void _onDragEntered(DropEventDetails _) => setState(() => _dragging = true); + + void _onDragExited(DropEventDetails _) => setState(() => _dragging = false); + + Future _onDragDone(DropDoneDetails details) async { + setState(() { + _dragging = false; + _busy = true; + _progress = 0; + }); + for (final file in details.files) { + final bytes = await file.readAsBytes(); + _model?.uploadBytes( + _server, + joinRemote(_path, file.name), + bytes, + file.name, + ); + } + } + + void _onDownload(RemoteEntry e) { + setState(() { + _busy = true; + _progress = 0; + }); + _model?.downloadFile(_server, joinRemote(_path, e.name), e.name); + } + + void _onDelete(RemoteEntry e) { + _model?.showApplicationNotification( + NotificationType.warning, + 'Delete "${e.name}"?', + e.isDir + ? 'Removes the directory (it must be empty).' + : 'Permanently deletes this file on the server.', + true, + true, + null, + action: 'Delete', + positiveAction: () => _onConfirmDelete(e), + ); + } + + void _onConfirmDelete(RemoteEntry e) { + Navigator.pop(context); + _model?.deleteEntry(_server, joinRemote(_path, e.name), e.isDir); + } + + void _onRowMenu(String value, RemoteEntry e) { + if (value == 'download') _onDownload(e); + if (value == 'delete') _onDelete(e); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectFiles ─────────────────────────────────────────────────────────── + + @override + void onHomeResolved(String path) { + if (!mounted) return; + setState(() => _path = path); + _model?.listDirectory(_server, path); + } + + @override + void onDirectoryLoaded(List entries) { + if (!mounted) return; + setState(() { + _entries = entries; + _loading = false; + _error = null; + }); + } + + @override + void onDirectoryFailed(String message) { + if (!mounted) return; + setState(() { + _loading = false; + _error = message; + }); + } + + @override + void onTransferFinished(String message) { + if (!mounted) return; + setState(() { + _busy = false; + _progress = null; + }); + _notify(message); + _onRefresh(); + } + + @override + void onEntryDeleted() { + _onRefresh(); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _pathBar() { + final jp = context.jp; + return Container( + color: _dragging ? PeachTheme.seed.withValues(alpha: 0.12) : jp.surface, + padding: const EdgeInsets.fromLTRB(8, 8, 12, 8), + child: Row( + children: [ + IconButton( + tooltip: 'Up', + onPressed: _busy || _path == '/' ? null : _onUp, + icon: const Icon(Icons.arrow_upward), + ), + Expanded( + child: text( + _dragging ? 'Drop files to upload here' : _path, + 13, + _dragging ? TextType.Bold : TextType.Regular, + color: _dragging ? PeachTheme.seed : jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _body() { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) return _errorView(); + if (_entries.isEmpty) { + return Center( + child: text( + 'Empty directory', + 14, + TextType.Regular, + color: context.jp.textFaint, + ), + ); + } + return ListView.separated( + itemCount: _entries.length, + separatorBuilder: (_, i) => Divider(height: 1, color: context.jp.border), + itemBuilder: (_, i) => _row(_entries[i]), + ); + } + + Widget _errorView() => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: colorNegative, size: 40), + const SizedBox(height: 12), + text( + _error!, + 14, + TextType.Regular, + color: colorNegative, + align: TextAlign.center, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _onRefresh, + icon: const Icon(Icons.refresh), + label: text('Retry', 14, TextType.Bold, color: colorWhite), + ), + ], + ), + ), + ); + + Widget _row(RemoteEntry e) { + final jp = context.jp; + return ListTile( + leading: Icon( + e.isDir ? Icons.folder : Icons.insert_drive_file_outlined, + color: e.isDir ? PeachTheme.accent : jp.textDim, + ), + title: text( + e.name, + 14, + TextType.Regular, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: e.isDir + ? null + : text(formatBytes(e.size), 12, TextType.Light, color: jp.textFaint), + onTap: e.isDir ? () => _onEnter(e) : null, + trailing: PopupMenuButton( + onSelected: (v) => _onRowMenu(v, e), + itemBuilder: (_) => [ + if (!e.isDir) + const PopupMenuItem( + value: 'download', + child: ListTile( + leading: Icon(Icons.download), + title: Text('Download'), + contentPadding: EdgeInsets.zero, + ), + ), + const PopupMenuItem( + value: 'delete', + child: ListTile( + leading: Icon(Icons.delete_outline), + title: Text('Delete'), + contentPadding: EdgeInsets.zero, + ), + ), + ], + ), + ); + } +} diff --git a/lib/Peach/see/files/ViewFiles.dart b/lib/Peach/see/files/ViewFiles.dart new file mode 100644 index 0000000..0fa3dff --- /dev/null +++ b/lib/Peach/see/files/ViewFiles.dart @@ -0,0 +1,118 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import '../../comms/Comms.dart' show TransferProgress; +import '../parent/ParentViewModel.dart'; +import 'ConnectFiles.dart'; + +class ViewFiles extends ParentViewModel { + ConnectFiles connection; + + ViewFiles(super.context, this.connection); + + /// Upload/download progress, subscribed to by the screen so the bar can + /// track a transfer as it runs. + Stream get transferProgress => + getDataManager().transferProgress; + + /// Resolves the login directory so the browser opens somewhere useful. + void resolveHome(ServerProfile server) async { + if (!await canReach(server, () => resolveHome(server))) return; + try { + final home = await getDataManager().resolveHome(server); + connection.onHomeResolved(home); + } catch (error) { + connection.onDirectoryFailed(error.toString()); + } + } + + void listDirectory(ServerProfile server, String path) async { + try { + final entries = await getDataManager().listDir(server, path); + connection.onDirectoryLoaded(entries); + } catch (error) { + connection.onDirectoryFailed(error.toString()); + } + } + + /// Uploads bytes already in memory (a drag-and-drop payload). + void uploadBytes( + ServerProfile server, + String remotePath, + Uint8List bytes, + String label, + ) async { + if (!await canReach( + server, + () => uploadBytes(server, remotePath, bytes, label), + )) { + return; + } + try { + await getDataManager().uploadFile(server, bytes, remotePath); + connection.onTransferFinished('Uploaded $label'); + } catch (error) { + connection.onTransferFinished('Upload failed: $error'); + } + } + + /// Prompts for a local file, then uploads it into [directory]. + void uploadPicked( + ServerProfile server, + String directory, + String Function(String, String) join, + ) async { + try { + final picked = await FilePicker.pickFiles(withData: true); + if (picked == null || picked.files.isEmpty) return; + final file = picked.files.first; + final bytes = file.bytes ?? await File(file.path!).readAsBytes(); + uploadBytes(server, join(directory, file.name), bytes, file.name); + } catch (error) { + handleError( + error, + () => uploadPicked(server, directory, join), + () {}, + "Retry", + ); + } + } + + /// Downloads [remotePath] and writes it wherever the user chooses. + void downloadFile( + ServerProfile server, + String remotePath, + String fileName, + ) async { + if (!await canReach( + server, + () => downloadFile(server, remotePath, fileName), + )) { + return; + } + try { + final bytes = await getDataManager().downloadFile(server, remotePath); + final saved = await FilePicker.saveFile(fileName: fileName, bytes: bytes); + connection.onTransferFinished( + saved == null ? 'Download cancelled' : 'Saved to $saved', + ); + } catch (error) { + connection.onTransferFinished('Download failed: $error'); + } + } + + void deleteEntry(ServerProfile server, String remotePath, bool isDir) async { + if (!await canReach(server, () => deleteEntry(server, remotePath, isDir))) { + return; + } + try { + await getDataManager().deleteRemote(server, remotePath, isDir); + connection.onEntryDeleted(); + } catch (error) { + connection.onTransferFinished('Delete failed: $error'); + } + } +} diff --git a/lib/Peach/see/health/ConnectHealth.dart b/lib/Peach/see/health/ConnectHealth.dart new file mode 100644 index 0000000..2d19c70 --- /dev/null +++ b/lib/Peach/see/health/ConnectHealth.dart @@ -0,0 +1,13 @@ +import '../../about/external/data/Health.dart'; + +abstract class ConnectHealth { + /// Every known service was marked as pending a check. + void onSweepStarted(); + + /// One service's check came back. Results arrive as they complete rather + /// than in a single batch, so the dashboard fills in progressively. + void onServiceChecked(String serviceId, HealthResult result); + + /// All services in this sweep have been checked. + void onSweepFinished(); +} diff --git a/lib/Peach/see/health/Health.dart b/lib/Peach/see/health/Health.dart new file mode 100644 index 0000000..fc997a5 --- /dev/null +++ b/lib/Peach/see/health/Health.dart @@ -0,0 +1,14 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import 'HealthState.dart'; + +/// Live health dashboard across all servers and their services. +class Health extends StatefulWidget { + final List servers; + + const Health({super.key, required this.servers}); + + @override + State createState() => HealthState(); +} diff --git a/lib/Peach/see/health/HealthState.dart b/lib/Peach/see/health/HealthState.dart new file mode 100644 index 0000000..c8c9906 --- /dev/null +++ b/lib/Peach/see/health/HealthState.dart @@ -0,0 +1,294 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Health.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/ServiceConfig.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/StatCard.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import 'ConnectHealth.dart'; +import 'Health.dart'; +import 'ViewHealth.dart'; + +class HealthState extends State implements ConnectHealth { + ViewHealth? _model; + + final Map _results = {}; + bool _running = false; + bool _auto = false; + Timer? _timer; + + List get _servers => widget.servers; + + int get _totalServices => _servers.fold(0, (n, s) => n + s.services.length); + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewHealth(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text('Health', 20, TextType.Bold, color: context.jp.text), + actions: [ + Row( + children: [ + text('Auto', 13, TextType.Regular, color: context.jp.textDim), + Switch(value: _auto, onChanged: _onToggleAuto), + ], + ), + IconButton( + tooltip: 'Run checks', + onPressed: _running ? null : _onRunChecks, + icon: _running + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + ), + const SizedBox(width: 4), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), + children: [ + _statRow(), + const SizedBox(height: 20), + if (_totalServices == 0) + _empty() + else + for (final server in _servers) + if (server.services.isNotEmpty) _serverGroup(server), + ], + ), + ), + ); + } + + void _onReady(ViewHealth model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _model?.runSweep(_servers); + } + + int _count(HealthStatus s) => + _results.values.where((r) => r.status == s).length; + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onRunChecks() => _model?.runSweep(_servers); + + void _onToggleAuto(bool v) { + setState(() => _auto = v); + _timer?.cancel(); + if (v) { + _timer = Timer.periodic( + const Duration(seconds: 30), + (_) => _model?.runSweep(_servers), + ); + } + } + + // ── ConnectHealth ────────────────────────────────────────────────────────── + + @override + void onSweepStarted() { + if (!mounted) return; + setState(() { + _running = true; + for (final server in _servers) { + for (final service in server.services) { + _results[service.id] = HealthResult.checking(); + } + } + }); + } + + @override + void onServiceChecked(String serviceId, HealthResult result) { + if (!mounted) return; + setState(() => _results[serviceId] = result); + } + + @override + void onSweepFinished() { + if (!mounted) return; + setState(() => _running = false); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _statRow() { + return LayoutBuilder( + builder: (context, c) { + const gap = 12.0; + final cols = c.maxWidth >= 640 ? 3 : 1; + final w = (c.maxWidth - gap * (cols - 1)) / cols; + final cards = [ + StatCard( + icon: Icons.favorite, + label: 'Healthy', + value: '${_count(HealthStatus.healthy)}', + color: PeachTheme.seed, + ), + StatCard( + icon: Icons.heart_broken, + label: 'Unhealthy', + value: '${_count(HealthStatus.unhealthy)}', + color: PeachTheme.danger, + ), + StatCard( + icon: Icons.dns, + label: 'Services', + value: '$_totalServices', + color: PeachTheme.accent, + ), + ]; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [for (final card in cards) SizedBox(width: w, child: card)], + ); + }, + ); + } + + Widget _empty() => Container( + padding: const EdgeInsets.symmetric(vertical: 40), + alignment: Alignment.center, + decoration: PeachTheme.panel(context), + child: text( + 'No services to monitor yet.', + 14, + TextType.Regular, + color: context.jp.textDim, + ), + ); + + Widget _serverGroup(ServerProfile server) { + final jp = context.jp; + return Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.dns, size: 15, color: PeachTheme.seed), + const SizedBox(width: 8), + text(server.name, 15, TextType.Bold, color: jp.text), + const SizedBox(width: 8), + text(server.host, 12, TextType.Regular, color: jp.textFaint), + ], + ), + const SizedBox(height: 10), + Container( + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + children: [ + for (var i = 0; i < server.services.length; i++) ...[ + if (i > 0) Divider(height: 1, color: jp.border), + _serviceRow(server.services[i]), + ], + ], + ), + ), + ], + ), + ); + } + + Widget _serviceRow(ServiceConfig svc) { + final jp = context.jp; + final result = _results[svc.id]; + final (color, icon, label) = _style(result?.status ?? HealthStatus.unknown); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Icon( + svc.isWeb ? Icons.language : Icons.settings_suggest, + size: 18, + color: jp.textDim, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + svc.name, + 14, + TextType.Regular, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + text( + '${svc.healthType.label}' + '${result != null ? ' Β· ${result.detail}' : ''}', + 11.5, + TextType.Light, + color: jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 10), + if (result?.status == HealthStatus.checking) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.45)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 5), + text(label, 11, TextType.Bold, color: color), + ], + ), + ), + ], + ), + ); + } + + (Color, IconData, String) _style(HealthStatus s) => switch (s) { + HealthStatus.healthy => (PeachTheme.seed, Icons.check_circle, 'HEALTHY'), + HealthStatus.unhealthy => (PeachTheme.danger, Icons.error, 'DOWN'), + HealthStatus.disabled => (context.jp.textFaint, Icons.remove_circle, 'OFF'), + HealthStatus.checking => (PeachTheme.accent, Icons.sync, 'CHECK'), + HealthStatus.unknown => (PeachTheme.warn, Icons.help, 'UNKNOWN'), + }; +} diff --git a/lib/Peach/see/health/ViewHealth.dart b/lib/Peach/see/health/ViewHealth.dart new file mode 100644 index 0000000..c1916c0 --- /dev/null +++ b/lib/Peach/see/health/ViewHealth.dart @@ -0,0 +1,48 @@ +import '../../about/external/data/Health.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectHealth.dart'; + +class ViewHealth extends ParentViewModel { + ConnectHealth connection; + + ViewHealth(super.context, this.connection); + + bool _running = false; + + bool get isRunning => _running; + + /// Checks every service on every server, reporting each result as it lands. + /// A server that can't be reached marks its own services unhealthy and the + /// sweep moves on, so one dead box doesn't stall the whole dashboard. + void runSweep(List servers) async { + if (_running) return; + _running = true; + notifyListeners(); + connection.onSweepStarted(); + + for (final server in servers) { + for (final service in server.services) { + try { + final result = await getDataManager().checkHealth(server, service); + connection.onServiceChecked(service.id, result); + } catch (error) { + connection.onServiceChecked( + service.id, + HealthResult(HealthStatus.unhealthy, _describe(error)), + ); + } + } + } + + getDataManager().disconnect(); + _running = false; + notifyListeners(); + connection.onSweepFinished(); + } + + String _describe(Object error) { + final text = error.toString(); + return text.length > 80 ? '${text.substring(0, 77)}…' : text; + } +} diff --git a/lib/Peach/see/history/ConnectHistory.dart b/lib/Peach/see/history/ConnectHistory.dart new file mode 100644 index 0000000..677d9a7 --- /dev/null +++ b/lib/Peach/see/history/ConnectHistory.dart @@ -0,0 +1,7 @@ +import '../../about/internal/application/DeploymentRecord.dart'; + +abstract class ConnectHistory { + void onHistoryLoaded(List history); + + void onHistoryCleared(); +} diff --git a/lib/Peach/see/history/History.dart b/lib/Peach/see/history/History.dart new file mode 100644 index 0000000..3e281bd --- /dev/null +++ b/lib/Peach/see/history/History.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/DeploymentRecord.dart'; +import 'HistoryState.dart'; + +class History extends StatefulWidget { + /// Seeded by the caller so the timeline paints immediately; the ViewModel + /// re-reads it on open. + final List history; + + const History({super.key, this.history = const []}); + + @override + State createState() => HistoryState(); +} diff --git a/lib/Peach/see/history/HistoryState.dart b/lib/Peach/see/history/HistoryState.dart new file mode 100644 index 0000000..d5d8c84 --- /dev/null +++ b/lib/Peach/see/history/HistoryState.dart @@ -0,0 +1,337 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/internal/application/DeploymentRecord.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import 'ConnectHistory.dart'; +import 'History.dart'; +import 'ViewHistory.dart'; + +class HistoryState extends State implements ConnectHistory { + ViewHistory? _model; + + List _history = []; + + @override + void initState() { + super.initState(); + _history = widget.history; + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewHistory(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + 'Deployment history', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + if (_history.isNotEmpty) + IconButton( + tooltip: 'Clear history', + onPressed: _onClear, + icon: const Icon(Icons.delete_sweep), + ), + const SizedBox(width: 8), + ], + ), + body: _history.isEmpty ? _empty() : _timeline(), + ), + ); + } + + void _onReady(ViewHistory model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _model?.loadHistory(); + } + + // ── derived state ────────────────────────────────────────────────────────── + + /// Records (already newest-first) grouped by calendar day. + List>> get _groups { + final map = >{}; + for (final r in _history) { + final day = DateTime(r.time.year, r.time.month, r.time.day); + map.putIfAbsent(day, () => []).add(r); + } + return map.entries.toList()..sort((a, b) => b.key.compareTo(a.key)); + } + + String _dayLabel(DateTime d) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final diff = today.difference(d).inDays; + if (diff == 0) return 'Today'; + if (diff == 1) return 'Yesterday'; + if (diff < 7) return '$diff days ago'; + String two(int n) => n.toString().padLeft(2, '0'); + return '${d.year}-${two(d.month)}-${two(d.day)}'; + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onClear() { + _model?.showApplicationNotification( + NotificationType.warning, + 'Clear history?', + 'This deletes all tracked deployment records. It cannot be undone.', + true, + true, + null, + action: 'Clear history', + positiveAction: _onConfirmClear, + ); + } + + void _onConfirmClear() { + Navigator.pop(context); + _model?.clearHistory(); + } + + // ── ConnectHistory ───────────────────────────────────────────────────────── + + @override + void onHistoryLoaded(List history) { + if (!mounted) return; + setState(() => _history = history); + } + + @override + void onHistoryCleared() { + if (!mounted) return; + setState(() => _history = []); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _empty() => Center( + child: text( + 'No deployments recorded yet.', + 14, + TextType.Regular, + color: context.jp.textDim, + ), + ); + + Widget _timeline() { + final groups = _groups; + return ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: groups.length, + itemBuilder: (context, i) => _dayGroup( + _dayLabel(groups[i].key), + groups[i].value, + i == groups.length - 1, + ), + ); + } + + Widget _dayGroup(String label, List records, bool isLast) { + final jp = context.jp; + final ok = records.where((r) => r.status == DeployStatus.success).length; + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline rail: dot + connecting line. + Column( + children: [ + Container( + width: 14, + height: 14, + margin: const EdgeInsets.only(top: 4), + decoration: BoxDecoration( + color: PeachTheme.seed, + shape: BoxShape.circle, + boxShadow: PeachTheme.glow( + PeachTheme.seed, + opacity: 0.5, + blur: 10, + ), + ), + ), + Expanded( + child: Container( + width: 2, + margin: const EdgeInsets.symmetric(vertical: 4), + color: isLast ? Colors.transparent : jp.border, + ), + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 26), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + text(label, 16, TextType.Bold, color: jp.text), + const SizedBox(width: 8), + text( + '${records.length} deploys Β· $ok ok', + 12, + TextType.Regular, + color: jp.textFaint, + ), + ], + ), + const SizedBox(height: 12), + LayoutBuilder( + builder: (context, c) { + const gap = 12.0; + final cols = (c.maxWidth / 300).floor().clamp(1, 4); + final w = (c.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final r in records) + SizedBox(width: w, child: _timelineTile(r)), + ], + ); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _timelineTile(DeploymentRecord record) { + final jp = context.jp; + final (color, icon) = switch (record.status) { + DeployStatus.success => (PeachTheme.seed, Icons.check_circle), + DeployStatus.failure => (PeachTheme.danger, Icons.error), + DeployStatus.reverted => (PeachTheme.warn, Icons.undo), + }; + String two(int n) => n.toString().padLeft(2, '0'); + final clock = '${two(record.time.hour)}:${two(record.time.minute)}'; + + return Container( + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.35)), + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text(clock, 15, TextType.Bold, color: jp.text), + text( + timeAgo(record.time), + 11, + TextType.Regular, + color: jp.textFaint, + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + ), + child: text( + record.status.label, + 10.5, + TextType.Bold, + color: color, + ), + ), + ], + ), + const SizedBox(height: 12), + text( + '${record.serverName} Β· ${record.serviceLabel}', + 14, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + _metaRow( + Icons.inventory_2, + record.jarName.isEmpty ? 'β€”' : record.jarName, + 12, + jp.textDim, + ), + const SizedBox(height: 3), + _metaRow( + Icons.folder_outlined, + record.remotePath, + 11.5, + jp.textFaint, + ), + if (record.message.isNotEmpty) ...[ + const SizedBox(height: 8), + text( + record.message, + 11.5, + TextType.Regular, + color: jp.textDim, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ); + } + + Widget _metaRow(IconData icon, String label, double size, Color color) => Row( + children: [ + Icon(icon, size: 12, color: context.jp.textFaint), + const SizedBox(width: 5), + Expanded( + child: text( + label, + size, + TextType.Regular, + color: color, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); +} diff --git a/lib/Peach/see/history/ViewHistory.dart b/lib/Peach/see/history/ViewHistory.dart new file mode 100644 index 0000000..fdc0cd3 --- /dev/null +++ b/lib/Peach/see/history/ViewHistory.dart @@ -0,0 +1,28 @@ +import '../parent/ParentViewModel.dart'; +import 'ConnectHistory.dart'; + +class ViewHistory extends ParentViewModel { + ConnectHistory connection; + + ViewHistory(super.context, this.connection); + + void loadHistory() async { + try { + final history = await getDataManager().getDeployments(); + connection.onHistoryLoaded(history); + } catch (error) { + handleError(error, loadHistory, () {}, "Retry"); + } + } + + void clearHistory() async { + showLoading("Clearing history …"); + try { + await getDataManager().clearDeployments(); + closeLoading(); + connection.onHistoryCleared(); + } catch (error) { + handleError(error, clearHistory, () {}, "Retry"); + } + } +} diff --git a/lib/Peach/see/home/ConnectHome.dart b/lib/Peach/see/home/ConnectHome.dart new file mode 100644 index 0000000..aff0c60 --- /dev/null +++ b/lib/Peach/see/home/ConnectHome.dart @@ -0,0 +1,16 @@ +import '../../about/internal/application/DeploymentRecord.dart'; +import '../../about/internal/application/ServerProfile.dart'; + +abstract class ConnectHome { + /// The dashboard's data, freshly read from storage. + void onDashboardLoaded( + List servers, + List history, + ); + + /// A server was saved; [server] is the stored version. + void onServerSaved(ServerProfile server); + + /// The theme preference changed to [mode]. + void onThemeChanged(String mode); +} diff --git a/lib/Peach/see/home/Home.dart b/lib/Peach/see/home/Home.dart new file mode 100644 index 0000000..a277026 --- /dev/null +++ b/lib/Peach/see/home/Home.dart @@ -0,0 +1,17 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/DeploymentRecord.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import 'HomeState.dart'; + +class Home extends StatefulWidget { + /// Seeded by the splash so the dashboard paints populated on first frame; + /// every later change is reloaded through the ViewModel. + final List servers; + final List history; + + const Home({super.key, this.servers = const [], this.history = const []}); + + @override + State createState() => HomeState(); +} diff --git a/lib/Peach/see/home/HomeState.dart b/lib/Peach/see/home/HomeState.dart new file mode 100644 index 0000000..85ecb75 --- /dev/null +++ b/lib/Peach/see/home/HomeState.dart @@ -0,0 +1,723 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/internal/application/DeploymentRecord.dart'; +import '../../about/internal/application/NavigatorType.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/ServiceConfig.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../configs/Navigator.dart'; +import '../../configs/ThemeController.dart'; +import '../../designs/ActivityChart.dart'; +import '../../designs/InsightsCard.dart'; +import '../../designs/RecentDeploys.dart'; +import '../../designs/Responsive.dart'; +import '../../designs/StatCard.dart'; +import '../../designs/StatsDonut.dart'; +import '../../designs/navigation/SideNav.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../health/Health.dart'; +import '../history/History.dart'; +import '../server/detail/ServerDetail.dart'; +import '../server/editor/ServerEditor.dart'; +import '../server/setup/ServerSetup.dart'; +import 'ConnectHome.dart'; +import 'Home.dart'; +import 'ViewHome.dart'; + +enum HomeView { list, grid } + +class HomeState extends State implements ConnectHome { + static const _viewKey = 'peach.homeview'; + + ViewHome? _model; + + List _servers = []; + List _history = []; + HomeView _view = HomeView.grid; + + @override + void initState() { + super.initState(); + _servers = widget.servers; + _history = widget.history; + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewHome(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => LayoutBuilder( + // The rail only earns its space on a real desktop width, so the + // breakpoint here is wider than Responsive's tablet cut-off. + builder: (context, constraints) => Responsive( + mobile: _mobileView(), + tablet: constraints.maxWidth >= 900 + ? _desktopView(constraints.maxWidth) + : _tabletView(), + desktop: _desktopView(constraints.maxWidth), + ), + ), + ); + } + + void _onReady(ViewHome model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + await _loadView(); + _model?.loadDashboard(); + } + + // ── derived state ────────────────────────────────────────────────────────── + + int get _success => + _history.where((h) => h.status == DeployStatus.success).length; + + int get _failed => + _history.where((h) => h.status == DeployStatus.failure).length; + + int get _reverted => + _history.where((h) => h.status == DeployStatus.reverted).length; + + int get _rate { + final attempted = _success + _failed; + return attempted == 0 ? 0 : ((_success / attempted) * 100).round(); + } + + int get _totalServices => _servers.fold(0, (n, s) => n + s.services.length); + + // ── view preference ──────────────────────────────────────────────────────── + + Future _loadView() async { + final prefs = await SharedPreferences.getInstance(); + final stored = prefs.getString(_viewKey); + if (stored == HomeView.list.name && mounted) { + setState(() => _view = HomeView.list); + } + } + + Future _setView(HomeView v) async { + setState(() => _view = v); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_viewKey, v.name); + } + + void _onGridView() => _setView(HomeView.grid); + + void _onListView() => _setView(HomeView.list); + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onCycleTheme() => _model?.cycleTheme(ThemeController.name); + + Future _onNewServer() async { + final created = await PeachNavigation().navigateToPageWithData( + const ServerEditor(), + context, + ); + if (created is! ServerProfile) return; + _model?.saveServer(created); + if (!mounted) return; + // Guided environment setup for the freshly-registered server. + await PeachNavigation().navigateToPageWithData( + ServerSetup(server: created), + context, + ); + } + + Future _onOpenServer(ServerProfile server) async { + await PeachNavigation().navigateToPageWithData( + ServerDetail(server: server, history: _history), + context, + ); + // The detail screen can add, edit or remove services and record deploys. + _model?.loadDashboard(); + } + + Future _onOpenHistory() async { + await PeachNavigation().navigateToPageWithData( + History(history: _history), + context, + ); + _model?.loadDashboard(); + } + + void _onOpenHealth() { + PeachNavigation().navigateToPage( + NavigatorType.justOpen, + Health(servers: _servers), + context, + ); + } + + // ── ConnectHome ──────────────────────────────────────────────────────────── + + @override + void onDashboardLoaded( + List servers, + List history, + ) { + if (!mounted) return; + setState(() { + _servers = servers; + _history = history; + }); + } + + @override + void onServerSaved(ServerProfile server) { + // The dashboard reload that follows carries the new value; nothing extra + // to do here beyond keeping the interface honest. + } + + @override + void onThemeChanged(String mode) { + ThemeController.apply(mode); + } + + // ── layouts ──────────────────────────────────────────────────────────────── + + Widget _desktopView(double width) { + return Scaffold( + body: Row( + children: [ + SideNav( + expanded: width >= 1140, + themeMode: ThemeController.mode.value, + onCycleTheme: () async => _onCycleTheme(), + onNewServer: _onNewServer, + items: [ + NavItemData( + icon: Icons.grid_view_rounded, + label: 'Dashboard', + selected: true, + onTap: _onDashboardTap, + ), + NavItemData( + icon: Icons.monitor_heart, + label: 'Health', + badge: _totalServices, + onTap: _onOpenHealth, + ), + NavItemData( + icon: Icons.history, + label: 'History', + badge: _history.length, + onTap: _onOpenHistory, + ), + ], + ), + Expanded(child: _mainArea()), + ], + ), + ); + } + + /// Tablet sits between the two: the dashboard body without the side rail. + Widget _tabletView() => Scaffold(body: _mainArea()); + + Widget _mobileView() { + final jp = context.jp; + final (themeIcon, themeTip) = switch (ThemeController.mode.value) { + ThemeMode.dark => (Icons.dark_mode, 'Dark theme'), + ThemeMode.light => (Icons.light_mode, 'Light theme'), + ThemeMode.system => (Icons.brightness_auto, 'System theme'), + }; + return Scaffold( + appBar: AppBar( + titleSpacing: 20, + title: Row( + children: [ + Icon(Icons.rocket_launch, color: colorPrimary), + const SizedBox(width: 10), + text('Peach', 17, TextType.Bold, color: jp.text), + ], + ), + actions: [ + IconButton( + tooltip: themeTip, + onPressed: _onCycleTheme, + icon: Icon(themeIcon), + ), + _viewToggle(jp), + const SizedBox(width: 4), + IconButton( + tooltip: 'Health', + onPressed: _onOpenHealth, + icon: const Icon(Icons.monitor_heart), + ), + IconButton( + tooltip: 'History', + onPressed: _onOpenHistory, + icon: const Icon(Icons.history), + ), + const SizedBox(width: 8), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _onNewServer, + icon: const Icon(Icons.add), + label: text('New server', 14, TextType.Bold, color: colorWhite), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + _statRow(), + const SizedBox(height: 16), + _bento(), + const SizedBox(height: 24), + _sectionHeader('SERVERS', _servers.length), + const SizedBox(height: 12), + if (_servers.isEmpty) _emptyServers() else _serversWrap(), + ], + ), + ); + } + + void _onDashboardTap() {} + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _mainArea() { + final jp = context.jp; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(28, 22, 28, 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text('Dashboard', 26, TextType.Bold, color: jp.text), + const SizedBox(height: 2), + text( + '${_servers.length} servers Β· ${_history.length} deployments', + 14, + TextType.Regular, + color: jp.textDim, + ), + ], + ), + ), + _viewToggle(jp), + ], + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(28, 8, 28, 32), + children: [ + _statRow(), + const SizedBox(height: 16), + _bento(), + const SizedBox(height: 24), + _sectionHeader('SERVERS', _servers.length), + const SizedBox(height: 12), + if (_servers.isEmpty) _emptyServers() else _serversWrap(), + ], + ), + ), + ], + ); + } + + Widget _bento() { + final activity = ActivityChart(history: _history); + final donut = StatsDonut( + success: _success, + failed: _failed, + reverted: _reverted, + ); + final insights = InsightsCard( + servers: _servers, + history: _history, + onViewHistory: _onOpenHistory, + ); + final recent = RecentDeploys( + history: _history, + onViewAll: _onOpenHistory, + max: 5, + ); + + return LayoutBuilder( + builder: (context, c) { + if (c.maxWidth <= 760) { + return Column( + children: [ + activity, + const SizedBox(height: 16), + donut, + const SizedBox(height: 16), + insights, + if (_history.isNotEmpty) ...[const SizedBox(height: 16), recent], + ], + ); + } + return Column( + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 5, child: activity), + const SizedBox(width: 16), + Expanded(flex: 3, child: donut), + ], + ), + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 5, child: insights), + const SizedBox(width: 16), + Expanded( + flex: 3, + child: _history.isEmpty ? const SizedBox() : recent, + ), + ], + ), + ], + ); + }, + ); + } + + Widget _statRow() { + return LayoutBuilder( + builder: (context, c) { + const gap = 14.0; + final cols = c.maxWidth >= 900 + ? 4 + : c.maxWidth >= 560 + ? 2 + : 1; + final w = (c.maxWidth - gap * (cols - 1)) / cols; + final cards = [ + StatCard( + icon: Icons.rocket_launch, + label: 'Deployments', + value: '${_history.length}', + color: PeachTheme.accent, + ), + StatCard( + icon: Icons.verified, + label: 'Success rate', + value: _history.isEmpty ? 'β€”' : '$_rate%', + color: PeachTheme.seed, + ), + StatCard( + icon: Icons.error_outline, + label: 'Failed', + value: '$_failed', + color: PeachTheme.danger, + ), + StatCard( + icon: Icons.dns, + label: 'Servers', + value: '${_servers.length}', + color: PeachTheme.violet, + ), + ]; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [for (final card in cards) SizedBox(width: w, child: card)], + ); + }, + ); + } + + Widget _serversWrap() { + if (_view == HomeView.list) { + return Column( + children: [ + for (final s in _servers) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _serverListCard(s), + ), + ], + ); + } + return LayoutBuilder( + builder: (context, c) { + const gap = 14.0; + final cols = (c.maxWidth / 300).floor().clamp(1, 4); + final w = (c.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final s in _servers) + SizedBox(width: w, child: _serverGridCard(s)), + ], + ); + }, + ); + } + + int _deployCount(ServerProfile s) => + _history.where((h) => h.serverId == s.id).length; + + Widget _serverListCard(ServerProfile server) { + final jp = context.jp; + final count = _deployCount(server); + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _onOpenServer(server), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + _iconTile(46, 13), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + server.name, + 16, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + text( + '${server.username}@${server.host}:${server.port}', + 13, + TextType.Regular, + color: jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 10), + _kindChips(server), + const SizedBox(width: 14), + if (count > 0) + text( + '$count deploys', + 12, + TextType.Regular, + color: jp.textFaint, + ), + const SizedBox(width: 8), + Icon(Icons.chevron_right, color: jp.textFaint), + ], + ), + ), + ), + ); + } + + Widget _serverGridCard(ServerProfile server) { + final jp = context.jp; + final count = _deployCount(server); + return Card( + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _onOpenServer(server), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _iconTile(42, 12), + const Spacer(), + if (count > 0) + text('$count', 13, TextType.Bold, color: jp.textFaint), + ], + ), + const SizedBox(height: 14), + text( + server.name, + 16, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 3), + text( + '${server.username}@${server.host}', + 12, + TextType.Regular, + color: jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 14), + _kindChips(server), + ], + ), + ), + ), + ); + } + + Widget _iconTile(double size, double radius) => Container( + width: size, + height: size, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + PeachTheme.seed.withValues(alpha: 0.28), + PeachTheme.seed.withValues(alpha: 0.10), + ], + ), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: PeachTheme.seed.withValues(alpha: 0.35)), + ), + child: const Icon(Icons.dns, color: PeachTheme.seed), + ); + + Widget _kindChips(ServerProfile s) { + final jp = context.jp; + var jar = 0, web = 0; + for (final svc in s.services) { + if (svc.kind == ServiceKind.webApp) { + web++; + } else { + jar++; + } + } + + Widget chip(IconData icon, int n, Color color) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: color), + const SizedBox(width: 4), + text('$n', 11, TextType.Bold, color: color), + ], + ), + ); + + if (jar == 0 && web == 0) { + return text('no services', 11, TextType.Regular, color: jp.textFaint); + } + return Wrap( + spacing: 6, + children: [ + if (jar > 0) chip(Icons.settings_suggest, jar, PeachTheme.seed), + if (web > 0) chip(Icons.language, web, PeachTheme.accent), + ], + ); + } + + Widget _sectionHeader(String title, int count) { + return Row( + children: [ + text( + title, + 12, + TextType.Bold, + color: context.jp.textDim, + letterSpacing: 1.2, + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + decoration: BoxDecoration( + color: PeachTheme.seed.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(20), + ), + child: text('$count', 11, TextType.Bold, color: PeachTheme.seed), + ), + ], + ); + } + + Widget _viewToggle(JpColors jp) { + Widget btn(HomeView v, IconData icon, String tip, VoidCallback onTap) { + final active = _view == v; + return IconButton( + tooltip: tip, + onPressed: onTap, + icon: Icon(icon, color: active ? PeachTheme.seed : jp.textFaint), + ); + } + + return Container( + decoration: BoxDecoration( + color: jp.surface2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: jp.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + btn(HomeView.grid, Icons.grid_view, 'Grid view', _onGridView), + btn( + HomeView.list, + Icons.view_agenda_outlined, + 'List view', + _onListView, + ), + ], + ), + ); + } + + Widget _emptyServers() { + final jp = context.jp; + return Container( + padding: const EdgeInsets.symmetric(vertical: 48), + decoration: PeachTheme.panel(context), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.dns, + size: 56, + color: PeachTheme.seed.withValues(alpha: 0.6), + ), + const SizedBox(height: 16), + text('No servers yet', 20, TextType.Bold, color: jp.text), + const SizedBox(height: 6), + text( + 'Register a server, then add its services.', + 14, + TextType.Regular, + color: jp.textDim, + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _onNewServer, + icon: const Icon(Icons.add), + label: text('New server', 14, TextType.Bold, color: colorWhite), + ), + ], + ), + ); + } +} diff --git a/lib/Peach/see/home/ViewHome.dart b/lib/Peach/see/home/ViewHome.dart new file mode 100644 index 0000000..65c59b9 --- /dev/null +++ b/lib/Peach/see/home/ViewHome.dart @@ -0,0 +1,87 @@ +import '../../about/internal/application/DeploymentRecord.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectHome.dart'; + +class ViewHome extends ParentViewModel { + ConnectHome connection; + + ViewHome(super.context, this.connection); + + /// Re-reads servers and history. Called on open and whenever a child screen + /// pops back having changed something. + void loadDashboard() async { + try { + final servers = await getDataManager().getServers(); + final history = await getDataManager().getDeployments(); + connection.onDashboardLoaded(servers, history); + } catch (error) { + handleError(error, loadDashboard, () {}, "Retry"); + } + } + + /// Inserts or updates [server] in storage, then refreshes the dashboard. + void saveServer(ServerProfile server) async { + try { + final servers = await getDataManager().getServers(); + final index = servers.indexWhere((s) => s.id == server.id); + if (index >= 0) { + servers[index] = server; + } else { + servers.add(server); + } + await getDataManager().setServers(servers); + connection.onServerSaved(server); + loadDashboard(); + } catch (error) { + handleError(error, () => saveServer(server), () {}, "Retry"); + } + } + + /// Removes [server] and its stored password. + void deleteServer(ServerProfile server) async { + try { + final servers = await getDataManager().getServers(); + servers.removeWhere((s) => s.id == server.id); + await getDataManager().setServers(servers); + await getDataManager().deleteServerSecret(server.id); + loadDashboard(); + } catch (error) { + handleError(error, () => deleteServer(server), () {}, "Retry"); + } + } + + /// Appends a deployment outcome to the history. + void recordDeployment(DeploymentRecord record) async { + try { + await getDataManager().addDeployment(record); + loadDashboard(); + } catch (error) { + handleError(error, () => recordDeployment(record), () {}, "Retry"); + } + } + + void clearHistory() async { + try { + await getDataManager().clearDeployments(); + loadDashboard(); + } catch (error) { + handleError(error, clearHistory, () {}, "Retry"); + } + } + + /// Advances the theme dark β†’ light β†’ system β†’ dark and persists the choice. + void cycleTheme(String current) async { + try { + final next = switch (current) { + 'dark' => 'light', + 'light' => 'system', + _ => 'dark', + }; + await getDataManager().setThemeMode(next); + connection.onThemeChanged(next); + } catch (error) { + handleError(error, () => cycleTheme(current), () {}, "Retry"); + } + } +} diff --git a/lib/Peach/see/kafka/ConnectKafka.dart b/lib/Peach/see/kafka/ConnectKafka.dart new file mode 100644 index 0000000..ceebdbd --- /dev/null +++ b/lib/Peach/see/kafka/ConnectKafka.dart @@ -0,0 +1,36 @@ +import '../../about/external/data/Kafka.dart'; + +/// One scan's worth of cluster state. +class KafkaScan { + final List topics; + final List groups; + final KafkaCluster cluster; + + const KafkaScan({ + required this.topics, + required this.groups, + required this.cluster, + }); + + /// Nothing came back at all β€” usually an unreachable bootstrap server rather + /// than a genuinely empty cluster. + bool get isEmpty => + topics.isEmpty && groups.isEmpty && cluster.brokers.isEmpty; +} + +abstract class ConnectKafka { + /// Whether the Kafka CLI tools were found, and where. + void onKafkaDetected(KafkaStatus status); + + /// Detection itself failed (the server couldn't be probed). + void onDetectFailed(String message); + + /// Topics, groups and cluster health, freshly read. + void onScanned(KafkaScan scan); + + /// The scan failed. + void onScanFailed(String message); + + /// Output of the broker probe, to show in a sheet. + void onProbeOutput(String title, String output); +} diff --git a/lib/Peach/see/kafka/Kafka.dart b/lib/Peach/see/kafka/Kafka.dart new file mode 100644 index 0000000..0450b3a --- /dev/null +++ b/lib/Peach/see/kafka/Kafka.dart @@ -0,0 +1,16 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import 'KafkaState.dart'; + +/// Live Kafka monitoring over SSH: topics with message offsets, consumer +/// groups with lag, and cluster/broker health. Everything comes from the +/// standard Kafka CLI tools run on the server. +class Kafka extends StatefulWidget { + final ServerProfile server; + + const Kafka({super.key, required this.server}); + + @override + State createState() => KafkaState(); +} diff --git a/lib/Peach/see/kafka/KafkaState.dart b/lib/Peach/see/kafka/KafkaState.dart new file mode 100644 index 0000000..02d728c --- /dev/null +++ b/lib/Peach/see/kafka/KafkaState.dart @@ -0,0 +1,1179 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Kafka.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/OutputSheet.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/CommonUtils.dart'; +import 'ConnectKafka.dart'; +import 'Kafka.dart'; +import 'ViewKafka.dart'; + +class KafkaState extends State + with SingleTickerProviderStateMixin + implements ConnectKafka { + ViewKafka? _model; + + late final TabController _tabs; + + final _bootstrap = TextEditingController(text: 'localhost:9092'); + final _binDir = TextEditingController(); + String _suffix = '.sh'; + + KafkaStatus? _status; + List _topics = []; + List _groups = []; + KafkaCluster? _cluster; + + bool _loading = true; + bool _detecting = true; + bool _showInternal = false; + String? _error; + + ServerProfile get _server => widget.server; + + String get _bootstrapServer => _bootstrap.text.trim().isEmpty + ? 'localhost:9092' + : _bootstrap.text.trim(); + + bool get _detected => _status?.detected ?? false; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 3, vsync: this); + } + + @override + void dispose() { + _tabs.dispose(); + _bootstrap.dispose(); + _binDir.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewKafka(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + 'Kafka Β· ${_server.name}', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + if (_detected) + IconButton( + tooltip: 'Probe connection', + onPressed: _onProbe, + icon: const Icon(Icons.wifi_tethering), + ), + IconButton( + tooltip: 'Refresh', + onPressed: (_loading || _detecting) ? null : _onScan, + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 4), + ], + bottom: _detected + ? PreferredSize( + preferredSize: const Size.fromHeight(48), + child: TabBar( + controller: _tabs, + labelStyle: const TextStyle( + fontWeight: FontWeight.w800, + fontSize: 13, + ), + tabs: [ + Tab(text: 'Topics Β· ${_topics.length}'), + Tab(text: 'Groups Β· ${_groups.length}'), + Tab(text: 'Cluster Β· ${_cluster?.brokers.length ?? 0}'), + ], + ), + ) + : null, + ), + body: _detecting + ? const Center(child: CircularProgressIndicator()) + : !_detected + ? _notDetected() + : Column( + children: [ + _connectionBar(), + if (_loading) const LinearProgressIndicator(minHeight: 3), + if (_error != null) _errorBar(), + Expanded( + child: TabBarView( + controller: _tabs, + children: [_topicsTab(), _groupsTab(), _clusterTab()], + ), + ), + ], + ), + ), + ); + } + + void _onReady(ViewKafka model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _model?.detectKafka(_server); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onScan() { + if (_binDir.text.trim().isEmpty) { + _notify('Set the Kafka bin directory first.'); + return; + } + setState(() { + _loading = true; + _error = null; + }); + _model?.scan( + _server, + binDir: _binDir.text.trim(), + suffix: _suffix, + bootstrap: _bootstrapServer, + includeInternal: _showInternal, + ); + } + + Future _onRefreshPull() async => _onScan(); + + void _onScanSubmitted(String _) => _onScan(); + + void _onRedetect() { + setState(() { + _detecting = true; + _error = null; + }); + _model?.detectKafka(_server); + } + + void _onProbe() => _model?.probeBroker( + _server, + binDir: _binDir.text.trim(), + suffix: _suffix, + bootstrap: _bootstrapServer, + ); + + /// Accepts a hand-entered bin directory when auto-detection came up empty. + void _onUseThisPath() { + if (_binDir.text.trim().isEmpty) { + _notify('Enter the bin directory.'); + return; + } + setState(() { + _suffix = '.sh'; + _status = KafkaStatus( + detected: true, + binDir: _binDir.text.trim(), + suffix: _suffix, + java: _status?.java ?? false, + ); + }); + _onScan(); + } + + void _onToggleInternal(bool value) { + setState(() => _showInternal = value); + _onScan(); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectKafka ─────────────────────────────────────────────────────────── + + @override + void onKafkaDetected(KafkaStatus status) { + if (!mounted) return; + setState(() { + _status = status; + _detecting = false; + if (status.detected) { + _binDir.text = status.binDir; + _suffix = status.suffix; + } + }); + if (status.detected) _onScan(); + } + + @override + void onDetectFailed(String message) { + if (!mounted) return; + setState(() { + _detecting = false; + _error = message; + }); + } + + @override + void onScanned(KafkaScan scan) { + if (!mounted) return; + setState(() { + _topics = scan.topics; + _groups = scan.groups; + _cluster = scan.cluster; + _loading = false; + _error = scan.isEmpty + ? 'No data returned. Check the bootstrap server ' + '($_bootstrapServer) is reachable from this host and the broker ' + 'is running.' + : null; + }); + } + + @override + void onScanFailed(String message) { + if (!mounted) return; + setState(() { + _loading = false; + _error = message; + }); + } + + @override + void onProbeOutput(String title, String output) { + if (!mounted) return; + showOutputSheet(context, title, output); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _connectionBar() { + final jp = context.jp; + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + decoration: BoxDecoration( + color: jp.surface, + border: Border(bottom: BorderSide(color: jp.border)), + ), + child: LayoutBuilder( + builder: (context, c) { + final bootstrap = TextField( + controller: _bootstrap, + onSubmitted: _onScanSubmitted, + decoration: const InputDecoration( + labelText: 'Bootstrap server', + hintText: 'localhost:9092', + prefixIcon: Icon(Icons.dns, size: 20), + isDense: true, + ), + ); + final binDir = TextField( + controller: _binDir, + onSubmitted: _onScanSubmitted, + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + decoration: const InputDecoration( + labelText: 'Kafka bin dir', + hintText: '/opt/kafka/bin', + prefixIcon: Icon(Icons.folder_outlined, size: 20), + isDense: true, + ), + ); + final scanButton = FilledButton.icon( + onPressed: _loading ? null : _onScan, + icon: const Icon(Icons.sync, size: 18), + label: text('Scan', 13, TextType.Bold, color: colorWhite), + ); + + if (c.maxWidth < 640) { + return Column( + children: [ + bootstrap, + const SizedBox(height: 10), + Row( + children: [ + Expanded(child: binDir), + const SizedBox(width: 10), + scanButton, + ], + ), + ], + ); + } + return Row( + children: [ + Expanded(flex: 3, child: bootstrap), + const SizedBox(width: 10), + Expanded(flex: 4, child: binDir), + const SizedBox(width: 10), + scanButton, + ], + ); + }, + ), + ); + } + + Widget _errorBar() => Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: PeachTheme.warn.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: PeachTheme.warn.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline, color: PeachTheme.warn, size: 20), + const SizedBox(width: 10), + Expanded( + child: text(_error!, 12.5, TextType.Regular, color: context.jp.text), + ), + ], + ), + ); + + Widget _notDetected() { + final jp = context.jp; + final hasJava = _status?.java ?? false; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 40), + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: PeachTheme.panel(context, glowColor: PeachTheme.warn), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + color: PeachTheme.warn.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(13), + ), + child: const Icon(Icons.hub, color: PeachTheme.warn), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + 'Kafka tools not found', + 16, + TextType.Bold, + color: jp.text, + ), + text( + 'kafka-topics wasn\'t on PATH or in the common dirs.', + 12.5, + TextType.Regular, + color: jp.textDim, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + text( + _status?.detail ?? '', + 12.5, + TextType.Regular, + color: jp.textDim, + ), + const SizedBox(height: 10), + Row( + children: [ + Icon( + hasJava ? Icons.check_circle : Icons.error_outline, + size: 16, + color: hasJava ? PeachTheme.seed : PeachTheme.danger, + ), + const SizedBox(width: 6), + text( + hasJava + ? 'Java runtime present' + : 'No Java runtime on PATH', + 12.5, + TextType.Regular, + color: jp.textDim, + ), + ], + ), + const SizedBox(height: 16), + text( + 'If Kafka is installed in a custom location, point to its ' + 'bin directory and scan:', + 12.5, + TextType.Regular, + color: jp.textDim, + ), + const SizedBox(height: 10), + TextField( + controller: _binDir, + style: const TextStyle(fontFamily: 'monospace', fontSize: 13), + decoration: const InputDecoration( + labelText: 'Kafka bin dir', + hintText: '/opt/kafka/bin', + prefixIcon: Icon(Icons.folder_outlined, size: 20), + isDense: true, + ), + ), + const SizedBox(height: 10), + TextField( + controller: _bootstrap, + decoration: const InputDecoration( + labelText: 'Bootstrap server', + hintText: 'localhost:9092', + prefixIcon: Icon(Icons.dns, size: 20), + isDense: true, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + FilledButton.icon( + onPressed: _onUseThisPath, + icon: const Icon(Icons.play_arrow, size: 18), + label: text( + 'Use this path', + 13, + TextType.Bold, + color: colorWhite, + ), + ), + const SizedBox(width: 10), + OutlinedButton.icon( + onPressed: _onRedetect, + icon: const Icon(Icons.refresh, size: 18), + label: text( + 'Re-detect', + 13, + TextType.Bold, + color: jp.textDim, + ), + ), + ], + ), + ], + ), + ), + ], + ); + } + + // ── topics ───────────────────────────────────────────────────────────────── + + Widget _topicsTab() { + final jp = context.jp; + final produced = _topics.fold(0, (s, t) => s + t.highWatermark); + final partitions = _topics.fold(0, (s, t) => s + t.partitionCount); + return RefreshIndicator( + onRefresh: _onRefreshPull, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 40), + children: [ + _statRow([ + ('Topics', '${_topics.length}', PeachTheme.seed), + ('Partitions', '$partitions', PeachTheme.accent), + ('Messages sent', formatCount(produced), PeachTheme.violet), + ]), + const SizedBox(height: 12), + Row( + children: [ + text( + 'TOPICS', + 12, + TextType.Bold, + color: jp.textDim, + letterSpacing: 1.2, + ), + const Spacer(), + text('internal', 11.5, TextType.Regular, color: jp.textFaint), + Switch(value: _showInternal, onChanged: _onToggleInternal), + ], + ), + const SizedBox(height: 4), + if (_topics.isEmpty) + _emptyBox( + Icons.topic_outlined, + 'No topics', + _loading ? 'Loading…' : 'This cluster has no topics yet.', + ) + else + ..._topics.map(_topicCard), + ], + ), + ); + } + + Widget _topicCard(KafkaTopic topic) { + final jp = context.jp; + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: PeachTheme.panel(context, radius: 16), + clipBehavior: Clip.antiAlias, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + leading: Icon( + Icons.topic, + color: topic.healthy ? PeachTheme.seed : PeachTheme.danger, + ), + title: text( + topic.name, + 14, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _chip('${topic.partitionCount}p', PeachTheme.accent), + _chip('RF ${topic.replicationFactor}', jp.textFaint), + _chip( + '~${formatCount(topic.retained)} msgs', + PeachTheme.violet, + ), + if (topic.underReplicatedCount > 0) + _chip( + '${topic.underReplicatedCount} under-repl', + PeachTheme.warn, + ), + if (topic.offlineCount > 0) + _chip('${topic.offlineCount} offline', PeachTheme.danger), + if (topic.internal) _chip('internal', jp.textFaint), + ], + ), + ), + children: [ + _partitionHeader(), + ...topic.partitions.map(_partitionRow), + if (topic.configs.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + text( + 'configs: ${topic.configs}', + 11, + TextType.Regular, + color: jp.textFaint, + ), + ], + ], + ), + ), + ); + } + + Widget _partitionHeader() { + final jp = context.jp; + return Padding( + padding: const EdgeInsets.only(bottom: 4, top: 2), + child: Row( + children: [ + _cell('P', jp.textFaint, 30, bold: true), + _cell('LEADER', jp.textFaint, 60, bold: true), + _cell('ISR/REP', jp.textFaint, 70, bold: true), + Expanded(child: _cell('OFFSETS', jp.textFaint, null, bold: true)), + _cell('MSGS', jp.textFaint, 70, bold: true, right: true), + ], + ), + ); + } + + Widget _partitionRow(KafkaPartition p) { + final jp = context.jp; + final degraded = p.underReplicated || p.offline; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + _cell('${p.id}', jp.text, 30), + _cell( + p.offline ? 'none' : '${p.leader}', + p.offline ? PeachTheme.danger : jp.text, + 60, + ), + _cell( + '${p.isr.length}/${p.replicas.length}', + degraded ? PeachTheme.warn : jp.textDim, + 70, + ), + Expanded( + child: _cell( + '${formatCount(p.earliest)} β†’ ${formatCount(p.latest)}', + jp.textDim, + null, + ), + ), + _cell(formatCount(p.messages), jp.text, 70, right: true, bold: true), + ], + ), + ); + } + + // ── consumer groups ──────────────────────────────────────────────────────── + + Widget _groupsTab() { + final jp = context.jp; + final totalLag = _groups.fold(0, (s, g) => s + g.totalLag); + final active = _groups.where((g) => g.active).length; + return RefreshIndicator( + onRefresh: _onRefreshPull, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 40), + children: [ + _statRow([ + ('Groups', '${_groups.length}', PeachTheme.seed), + ('Active', '$active', PeachTheme.accent), + ( + 'Total lag', + formatCount(totalLag), + totalLag > 0 ? PeachTheme.warn : PeachTheme.seed, + ), + ]), + const SizedBox(height: 12), + text( + 'CONSUMER GROUPS', + 12, + TextType.Bold, + color: jp.textDim, + letterSpacing: 1.2, + ), + const SizedBox(height: 8), + if (_groups.isEmpty) + _emptyBox( + Icons.groups_outlined, + 'No consumer groups', + _loading ? 'Loading…' : 'Nothing is consuming from this cluster.', + ) + else + ..._groups.map(_groupCard), + ], + ), + ); + } + + Widget _groupCard(KafkaConsumerGroup group) { + final jp = context.jp; + final (color, label) = switch (group.state.toLowerCase()) { + 'stable' => (PeachTheme.seed, 'STABLE'), + 'empty' => (jp.textFaint, 'EMPTY'), + 'dead' => (PeachTheme.danger, 'DEAD'), + 'preparingrebalance' => (PeachTheme.warn, 'REBALANCING'), + 'completingrebalance' => (PeachTheme.warn, 'REBALANCING'), + _ => ( + jp.textFaint, + group.state.isEmpty ? 'UNKNOWN' : group.state.toUpperCase(), + ), + }; + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: PeachTheme.panel(context, radius: 16), + clipBehavior: Clip.antiAlias, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 12), + leading: Icon( + Icons.groups, + color: group.active ? PeachTheme.seed : jp.textFaint, + ), + title: Row( + children: [ + Expanded( + child: text( + group.groupId, + 14, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + ), + child: text(label, 10, TextType.Bold, color: color), + ), + ], + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _chip('${group.members} members', PeachTheme.accent), + _chip('${group.topics.length} topics', jp.textFaint), + _chip( + 'lag ${formatCount(group.totalLag)}', + group.totalLag > 0 ? PeachTheme.warn : PeachTheme.seed, + ), + _chip( + '${formatCount(group.processed)} processed', + PeachTheme.violet, + ), + ], + ), + ), + children: [ + if (group.assignments.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: text( + 'No committed offsets (idle group).', + 12, + TextType.Regular, + color: jp.textFaint, + ), + ) + else ...[ + _assignHeader(), + ...group.assignments.map(_assignRow), + ], + ], + ), + ), + ); + } + + Widget _assignHeader() { + final jp = context.jp; + return Padding( + padding: const EdgeInsets.only(bottom: 4, top: 2), + child: Row( + children: [ + Expanded( + flex: 4, + child: _cell('TOPIC', jp.textFaint, null, bold: true), + ), + _cell('P', jp.textFaint, 26, bold: true), + _cell('CURRENT', jp.textFaint, 74, bold: true, right: true), + _cell('END', jp.textFaint, 74, bold: true, right: true), + _cell('LAG', jp.textFaint, 60, bold: true, right: true), + ], + ), + ); + } + + Widget _assignRow(KafkaGroupAssignment a) { + final jp = context.jp; + final live = a.consumerId.isNotEmpty && a.consumerId != '-'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(flex: 4, child: _cell(a.topic, jp.text, null)), + _cell('${a.partition}', jp.textDim, 26), + _cell(formatCount(a.currentOffset), jp.textDim, 74, right: true), + _cell(formatCount(a.logEndOffset), jp.textDim, 74, right: true), + _cell( + formatCount(a.lag), + a.lag > 0 ? PeachTheme.warn : jp.textDim, + 60, + right: true, + bold: true, + ), + ], + ), + if (live) + Padding( + padding: const EdgeInsets.only(left: 2, top: 1, bottom: 2), + child: text( + '${a.consumerId} ${a.host}', + 10, + TextType.Regular, + color: jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + // ── cluster ──────────────────────────────────────────────────────────────── + + Widget _clusterTab() { + final jp = context.jp; + final cluster = _cluster; + final healthy = cluster?.healthy ?? false; + return RefreshIndicator( + onRefresh: _onRefreshPull, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 40), + children: [ + _statRow([ + ('Brokers', '${cluster?.brokers.length ?? 0}', PeachTheme.seed), + ( + 'Under-repl', + '${cluster?.underReplicated.length ?? 0}', + (cluster?.underReplicated.isEmpty ?? true) + ? PeachTheme.seed + : PeachTheme.warn, + ), + ( + 'Offline', + '${cluster?.unavailable.length ?? 0}', + (cluster?.unavailable.isEmpty ?? true) + ? PeachTheme.seed + : PeachTheme.danger, + ), + ]), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: (healthy ? PeachTheme.seed : PeachTheme.danger).withValues( + alpha: 0.10, + ), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: (healthy ? PeachTheme.seed : PeachTheme.danger) + .withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Icon( + healthy ? Icons.verified : Icons.warning_amber_rounded, + color: healthy ? PeachTheme.seed : PeachTheme.danger, + ), + const SizedBox(width: 12), + Expanded( + child: text( + healthy + ? 'Cluster healthy β€” all partitions in sync.' + : '${cluster?.underReplicated.length ?? 0} under-replicated Β· ' + '${cluster?.unavailable.length ?? 0} offline partitions.', + 14, + TextType.Bold, + color: healthy ? PeachTheme.seed : PeachTheme.danger, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + if (cluster != null && + (cluster.clusterId.isNotEmpty || cluster.controllerId >= 0)) + Container( + margin: const EdgeInsets.only(bottom: 16), + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (cluster.clusterId.isNotEmpty) + _keyValue('Cluster ID', cluster.clusterId), + if (cluster.controllerId >= 0) + _keyValue('Controller', 'broker ${cluster.controllerId}'), + ], + ), + ), + text( + 'BROKERS', + 12, + TextType.Bold, + color: jp.textDim, + letterSpacing: 1.2, + ), + const SizedBox(height: 8), + if (cluster == null || cluster.brokers.isEmpty) + _emptyBox( + Icons.dns_outlined, + 'No brokers', + _loading + ? 'Loading…' + : 'Could not reach any broker at $_bootstrapServer.', + ) + else + LayoutBuilder( + builder: (context, c) { + const gap = 12.0; + final cols = c.maxWidth >= 620 ? 2 : 1; + final w = (c.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final broker in cluster.brokers) + SizedBox(width: w, child: _brokerCard(broker)), + ], + ); + }, + ), + if (cluster != null && cluster.underReplicated.isNotEmpty) ...[ + const SizedBox(height: 16), + _partitionListCard( + 'Under-replicated partitions', + cluster.underReplicated, + PeachTheme.warn, + ), + ], + if (cluster != null && cluster.unavailable.isNotEmpty) ...[ + const SizedBox(height: 16), + _partitionListCard( + 'Offline partitions', + cluster.unavailable, + PeachTheme.danger, + ), + ], + ], + ), + ); + } + + Widget _brokerCard(KafkaBroker broker) { + final jp = context.jp; + return Container( + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 16), + child: Row( + children: [ + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: PeachTheme.accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: text( + '${broker.id}', + 15, + TextType.Bold, + color: PeachTheme.accent, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + broker.endpoint, + 14, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + text( + broker.rack.isEmpty + ? 'broker ${broker.id}' + : 'rack ${broker.rack}', + 11.5, + TextType.Regular, + color: jp.textFaint, + ), + ], + ), + ), + if (broker.isController) + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: PeachTheme.seed.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + ), + child: text( + 'CONTROLLER', + 9.5, + TextType.Bold, + color: PeachTheme.seed, + ), + ), + ], + ), + ); + } + + Widget _partitionListCard(String title, List items, Color color) { + final jp = context.jp; + return Container( + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.warning_amber_rounded, size: 18, color: color), + const SizedBox(width: 8), + text(title, 14, TextType.Bold, color: jp.text), + const SizedBox(width: 8), + text('${items.length}', 14, TextType.Bold, color: color), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: items.map((s) => _chip(s, color)).toList(), + ), + ], + ), + ); + } + + // ── shared bits ──────────────────────────────────────────────────────────── + + Widget _statRow(List<(String, String, Color)> stats) { + return Row( + children: [ + for (var i = 0; i < stats.length; i++) ...[ + if (i > 0) const SizedBox(width: 10), + Expanded(child: _statCard(stats[i])), + ], + ], + ); + } + + Widget _statCard((String, String, Color) stat) { + final (label, value, color) = stat; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + value, + 22, + TextType.Bold, + color: color, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + text( + label.toUpperCase(), + 10.5, + TextType.Bold, + color: context.jp.textFaint, + letterSpacing: 0.6, + ), + ], + ), + ); + } + + Widget _chip(String label, Color color) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.13), + borderRadius: BorderRadius.circular(7), + border: Border.all(color: color.withValues(alpha: 0.30)), + ), + child: text(label, 11, TextType.Bold, color: color), + ); + + /// A fixed-width monospace cell, so the partition and assignment tables line + /// up as columns rather than drifting with content. + Widget _cell( + String label, + Color color, + double? width, { + bool bold = false, + bool right = false, + }) { + final child = Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: right ? TextAlign.right : TextAlign.left, + style: TextStyle( + fontSize: 11.5, + fontFamily: 'monospace', + fontWeight: bold ? FontWeight.w800 : FontWeight.w500, + color: color, + ), + ); + if (width == null) return child; + return SizedBox(width: width, child: child); + } + + Widget _keyValue(String key, String value) { + final jp = context.jp; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 90, + child: text(key, 12.5, TextType.Regular, color: jp.textFaint), + ), + Expanded( + child: SelectableText( + value, + style: TextStyle( + fontSize: 12.5, + fontFamily: 'monospace', + color: jp.text, + ), + ), + ), + ], + ), + ); + } + + Widget _emptyBox(IconData icon, String title, String subtitle) { + final jp = context.jp; + return Container( + padding: const EdgeInsets.symmetric(vertical: 40), + alignment: Alignment.center, + decoration: PeachTheme.panel(context), + child: Column( + children: [ + Icon(icon, size: 40, color: jp.textFaint), + const SizedBox(height: 10), + text(title, 14, TextType.Regular, color: jp.textDim), + const SizedBox(height: 4), + text( + subtitle, + 12, + TextType.Regular, + color: jp.textFaint, + align: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/Peach/see/kafka/ViewKafka.dart b/lib/Peach/see/kafka/ViewKafka.dart new file mode 100644 index 0000000..0b999bb --- /dev/null +++ b/lib/Peach/see/kafka/ViewKafka.dart @@ -0,0 +1,94 @@ +import '../../about/internal/application/ServerProfile.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectKafka.dart'; + +class ViewKafka extends ParentViewModel { + ConnectKafka connection; + + ViewKafka(super.context, this.connection); + + /// Looks for the Kafka CLI tools on PATH and in the usual install dirs. + void detectKafka(ServerProfile server) async { + if (!await canReach(server, () => detectKafka(server))) return; + try { + final status = await getDataManager().kafkaDetect(server); + connection.onKafkaDetected(status); + } catch (error) { + connection.onDetectFailed(error.toString()); + } + } + + /// Reads topics, consumer groups and cluster health in one pass. They're + /// three separate CLI invocations, so this is where they're sequenced. + void scan( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + required bool includeInternal, + }) async { + try { + final topics = await getDataManager().kafkaTopics( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + includeInternal: includeInternal, + ); + final groups = await getDataManager().kafkaGroups( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + ); + final cluster = await getDataManager().kafkaCluster( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + ); + + connection.onScanned( + KafkaScan(topics: topics, groups: groups, cluster: cluster), + ); + } catch (error) { + connection.onScanFailed(error.toString()); + } + } + + /// Runs `kafka-broker-api-versions` so the user can see exactly what the + /// broker says when a scan comes back empty. + void probeBroker( + ServerProfile server, { + required String binDir, + required String suffix, + required String bootstrap, + }) async { + showLoading("Probing $bootstrap …"); + try { + final output = await getDataManager().kafkaTest( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + ); + closeLoading(); + connection.onProbeOutput( + 'kafka-broker-api-versions Β· $bootstrap', + output.isEmpty ? '(no output β€” broker unreachable?)' : output, + ); + } catch (error) { + handleError( + error, + () => probeBroker( + server, + binDir: binDir, + suffix: suffix, + bootstrap: bootstrap, + ), + () {}, + "Retry", + ); + } + } +} diff --git a/lib/Peach/see/logs/ConnectLogs.dart b/lib/Peach/see/logs/ConnectLogs.dart new file mode 100644 index 0000000..039a872 --- /dev/null +++ b/lib/Peach/see/logs/ConnectLogs.dart @@ -0,0 +1,16 @@ +abstract class ConnectLogs { + /// A fetch finished (the lines themselves arrive on the log stream). + void onLogsFetched(); + + /// The unit's current systemd status, e.g. `active (running)` or `no-unit`. + void onStatusLoaded(String status); + + /// A start / stop / restart finished, with the message to surface. + void onControlFinished(String message); + + /// Live tail started or stopped. + void onFollowChanged(bool following); + + /// The filtered log text was written to [path], or cancelled when null. + void onLogsSaved(String? path); +} diff --git a/lib/Peach/see/logs/Logs.dart b/lib/Peach/see/logs/Logs.dart new file mode 100644 index 0000000..70782bd --- /dev/null +++ b/lib/Peach/see/logs/Logs.dart @@ -0,0 +1,17 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/ServiceConfig.dart'; +import 'LogsState.dart'; + +/// Read-only viewer plus lightweight service control: fetch, filter and +/// download logs, live-tail them, and start / stop / restart the unit. +class Logs extends StatefulWidget { + final ServerProfile server; + final ServiceConfig service; + + const Logs({super.key, required this.server, required this.service}); + + @override + State createState() => LogsState(); +} diff --git a/lib/Peach/see/logs/LogsState.dart b/lib/Peach/see/logs/LogsState.dart new file mode 100644 index 0000000..fbfaa93 --- /dev/null +++ b/lib/Peach/see/logs/LogsState.dart @@ -0,0 +1,449 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/ServiceConfig.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../comms/Comms.dart' show LogLine; +import '../../designs/LogConsole.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import 'ConnectLogs.dart'; +import 'Logs.dart'; +import 'ViewLogs.dart'; + +class LogsState extends State implements ConnectLogs { + ViewLogs? _model; + + StreamSubscription? _logSub; + final _lines = []; + final _searchController = TextEditingController(); + + bool _loading = true; + bool _following = false; + bool _busy = false; + String _status = 'unknown'; + String _query = ''; + DateTimeRange? _range; + + ServerProfile get _server => widget.server; + ServiceConfig get _service => widget.service; + + List get _filtered { + if (_query.trim().isEmpty) return _lines; + final q = _query.toLowerCase(); + return _lines.where((l) => l.text.toLowerCase().contains(q)).toList(); + } + + @override + void dispose() { + _logSub?.cancel(); + _model?.stopLogs(); + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewLogs(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + '${_service.name} Β· logs', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + IconButton( + tooltip: _range == null ? 'Filter by date' : 'Change date range', + onPressed: _busy ? null : _onPickRange, + icon: Icon( + Icons.date_range, + color: _range != null ? PeachTheme.seed : null, + ), + ), + IconButton( + tooltip: 'Download logs', + onPressed: _onDownload, + icon: const Icon(Icons.download), + ), + IconButton( + tooltip: 'Refresh', + onPressed: _loading || _busy ? null : _onRefresh, + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 4), + ], + ), + body: Column( + children: [ + _header(), + _controlBar(), + _searchBar(), + if (_loading || _busy) const LinearProgressIndicator(minHeight: 3), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 0), + child: LogConsole( + logs: _filtered, + height: null, + live: _following, + totalCount: _query.isEmpty ? null : _lines.length, + emptyMessage: _loading + ? 'Loading…' + : (_query.isNotEmpty ? 'No matches.' : 'No log output.'), + onClear: _onClearBacklog, + onCopy: _onCopy, + ), + ), + ), + _bottomBar(), + ], + ), + ), + ); + } + + void _onReady(ViewLogs model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _logSub = _model?.logs.listen(_onLogLine); + _model?.fetchLogs(_server, _service); + _model?.loadStatus(_server, _service); + } + + void _onLogLine(LogLine line) { + if (!mounted) return; + setState(() => _lines.add(line)); + } + + // ── status helpers ───────────────────────────────────────────────────────── + + bool get _isActive => + _status.contains('active') && !_status.contains('inactive'); + + bool get _hasUnit => _status != 'no-unit'; + + (Color, IconData) get _statusStyle { + if (_status.contains('failed')) return (PeachTheme.danger, Icons.error); + if (_isActive) return (PeachTheme.seed, Icons.play_circle); + if (_status.contains('activating')) { + return (PeachTheme.warn, Icons.hourglass_top); + } + return (context.jp.textFaint, Icons.stop_circle); + } + + String _unitLabel() { + if (_service.isWeb) { + final unit = _service.nginxService.isEmpty + ? 'nginx' + : _service.nginxService; + return 'nginx: $unit'; + } + return _service.serviceName.isEmpty + ? '(no unit)' + : 'unit: ${_service.serviceName}'; + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onRefresh() { + setState(() { + _loading = true; + _following = false; + _lines.clear(); + }); + _model?.fetchLogs( + _server, + _service, + since: _range?.start, + until: _range?.end, + ); + } + + void _onStart() => _control('start'); + + void _onStop() => _control('stop'); + + void _onRestart() => _control('restart'); + + void _control(String action) { + setState(() { + _busy = true; + _following = false; + }); + _model?.control(_server, _service, action); + } + + Future _onPickRange() async { + final now = DateTime.now(); + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(now.year - 3), + lastDate: now, + initialDateRange: _range, + ); + if (picked == null) return; + setState( + () => _range = DateTimeRange( + start: DateTime( + picked.start.year, + picked.start.month, + picked.start.day, + ), + end: DateTime( + picked.end.year, + picked.end.month, + picked.end.day, + 23, + 59, + 59, + ), + ), + ); + _onRefresh(); + } + + void _onClearRange() { + setState(() => _range = null); + _onRefresh(); + } + + void _onDownload() { + final lines = _filtered.map((l) => l.text).toList(); + if (lines.isEmpty) { + _notify('Nothing to download.'); + return; + } + _model?.saveLogs(_service.name, lines); + } + + void _onToggleFollow() => _model?.toggleFollow(_server, _service, _following); + + void _onClearBacklog() => setState(() => _lines.clear()); + + void _onCopy() { + Clipboard.setData( + ClipboardData(text: _filtered.map((l) => l.text).join('\n')), + ); + _notify('Logs copied.'); + } + + void _onSearchChanged(String v) => setState(() => _query = v); + + void _onClearSearch() { + _searchController.clear(); + setState(() => _query = ''); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectLogs ──────────────────────────────────────────────────────────── + + @override + void onLogsFetched() { + if (!mounted) return; + setState(() => _loading = false); + } + + @override + void onStatusLoaded(String status) { + if (!mounted) return; + setState(() => _status = status); + } + + @override + void onControlFinished(String message) { + if (!mounted) return; + setState(() => _busy = false); + _notify(message); + } + + @override + void onFollowChanged(bool following) { + if (!mounted) return; + setState(() => _following = following); + } + + @override + void onLogsSaved(String? path) { + _notify(path == null ? 'Download cancelled' : 'Saved to $path'); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _header() { + final jp = context.jp; + final (color, icon) = _statusStyle; + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + decoration: BoxDecoration( + color: jp.surface, + border: Border(bottom: BorderSide(color: jp.border)), + ), + child: Row( + children: [ + const Icon(Icons.dns, size: 15, color: PeachTheme.seed), + const SizedBox(width: 6), + Expanded( + child: text( + '${_server.username}@${_server.host} Β· ${_unitLabel()}', + 12.5, + TextType.Regular, + color: jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 5), + text( + _status.toUpperCase(), + 10.5, + TextType.Bold, + color: color, + letterSpacing: 0.4, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _controlBar() { + if (!_hasUnit) return const SizedBox.shrink(); + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(12, 10, 12, 4), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // Contextual: Start only when stopped, Stop only when running. + if (!_isActive) + _ctlButton(Icons.play_arrow, 'Start', PeachTheme.seed, _onStart), + if (_isActive) + _ctlButton(Icons.stop, 'Stop', PeachTheme.danger, _onStop), + _ctlButton( + Icons.restart_alt, + _service.isWeb ? 'Reload' : 'Restart', + PeachTheme.accent, + _onRestart, + ), + if (_range != null) _rangeChip(), + ], + ), + ); + } + + Widget _ctlButton( + IconData icon, + String label, + Color color, + VoidCallback onTap, + ) { + return OutlinedButton.icon( + onPressed: _busy ? null : onTap, + icon: Icon(icon, size: 18), + label: text(label, 13, TextType.Bold, color: color), + style: OutlinedButton.styleFrom( + foregroundColor: color, + side: BorderSide(color: color.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + + Widget _rangeChip() { + String d(DateTime t) => '${t.month}/${t.day}'; + return InputChip( + avatar: const Icon(Icons.date_range, size: 16), + label: Text('${d(_range!.start)} – ${d(_range!.end)}'), + onDeleted: _onClearRange, + onPressed: _onPickRange, + ); + } + + Widget _searchBar() { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: TextField( + controller: _searchController, + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: 'Search logs…', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _query.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: _onClearSearch, + ), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ); + } + + Widget _bottomBar() { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _busy ? null : _onToggleFollow, + icon: Icon(_following ? Icons.stop_circle : Icons.stream), + label: text( + _following ? 'Stop live tail' : 'Live tail', + 14, + TextType.Bold, + color: colorWhite, + ), + style: _following + ? FilledButton.styleFrom( + backgroundColor: PeachTheme.danger, + foregroundColor: colorWhite, + ) + : null, + ), + ), + ), + ); + } +} diff --git a/lib/Peach/see/logs/ViewLogs.dart b/lib/Peach/see/logs/ViewLogs.dart new file mode 100644 index 0000000..699f097 --- /dev/null +++ b/lib/Peach/see/logs/ViewLogs.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/ServiceConfig.dart'; +import '../../comms/Comms.dart' show LogLine; +import '../parent/ParentViewModel.dart'; +import 'ConnectLogs.dart'; + +class ViewLogs extends ParentViewModel { + ConnectLogs connection; + + ViewLogs(super.context, this.connection); + + /// Log lines as they arrive from the server. The screen subscribes to this + /// directly β€” a stream of remote output has no request/response shape to + /// hand back through the Connect interface one call at a time. + Stream get logs => getDataManager().logs; + + /// Pulls the journal for [service], optionally bounded by a date range. + void fetchLogs( + ServerProfile server, + ServiceConfig service, { + DateTime? since, + DateTime? until, + }) async { + if (!await canReach( + server, + () => fetchLogs(server, service, since: since, until: until), + )) { + return; + } + getDataManager().stopLogs(); + try { + await getDataManager().fetchLogs( + server, + service, + since: since, + until: until, + ); + connection.onLogsFetched(); + } catch (error) { + connection.onLogsFetched(); + handleError( + error, + () => fetchLogs(server, service, since: since, until: until), + () {}, + "Retry", + ); + } + } + + /// Reads the unit's systemd state. Failures are reported as `unknown` + /// rather than raised β€” the status pill is ambient, not an operation the + /// user asked for. + void loadStatus(ServerProfile server, ServiceConfig service) async { + try { + final status = await getDataManager().serviceStatus(server, service); + connection.onStatusLoaded(status); + } catch (_) { + connection.onStatusLoaded('unknown'); + } + } + + /// start | stop | restart on the service's unit. + void control( + ServerProfile server, + ServiceConfig service, + String action, + ) async { + if (!await canReach(server, () => control(server, service, action))) return; + getDataManager().stopLogs(); + try { + final result = await getDataManager().serviceControl( + server, + service, + action, + ); + connection.onControlFinished(result.message); + loadStatus(server, service); + } catch (error) { + handleError( + error, + () => control(server, service, action), + () {}, + "Retry", + ); + } + } + + /// Starts or stops the live tail. + void toggleFollow( + ServerProfile server, + ServiceConfig service, + bool currentlyFollowing, + ) async { + if (currentlyFollowing) { + getDataManager().stopLogs(); + connection.onFollowChanged(false); + return; + } + if (!await canReach( + server, + () => toggleFollow(server, service, currentlyFollowing), + )) { + return; + } + connection.onFollowChanged(true); + try { + await getDataManager().followLogs(server, service); + } catch (error) { + connection.onFollowChanged(false); + handleError( + error, + () => toggleFollow(server, service, false), + () {}, + "Retry", + ); + } + } + + /// Writes [lines] to a file the user picks. + void saveLogs(String serviceName, List lines) async { + try { + final body = lines.join('\n'); + final stamp = DateTime.now() + .toIso8601String() + .replaceAll(':', '-') + .split('.') + .first; + final safeName = serviceName.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); + final path = await FilePicker.saveFile( + dialogTitle: 'Save logs', + fileName: '$safeName-logs-$stamp.txt', + bytes: Uint8List.fromList(utf8.encode(body)), + ); + connection.onLogsSaved(path); + } catch (error) { + handleError(error, () => saveLogs(serviceName, lines), () {}, "Retry"); + } + } + + /// Stops any tail still running when the screen goes away. + void stopLogs() => getDataManager().stopLogs(); +} diff --git a/lib/Peach/see/parent/ParentViewModel.dart b/lib/Peach/see/parent/ParentViewModel.dart new file mode 100644 index 0000000..2b0d89b --- /dev/null +++ b/lib/Peach/see/parent/ParentViewModel.dart @@ -0,0 +1,722 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dartssh2/dartssh2.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../about/external/data/PeachError.dart'; +import '../../about/internal/application/NotificationType.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/Severity.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../about/internal/file/FileStorage.dart'; +import '../../comms/Comms.dart'; +import '../../designs/text/Text.dart'; +import '../../informatics/AppDataManager.dart'; +import '../../informatics/DataManager.dart'; +import '../../memory/InternalMemory.dart'; +import '../../utils/Colors.dart'; + +/// The base every ViewModel extends. It owns the three cross-cutting overlays +/// (loading, unreachable-server, error), the single [DataManager] instance, and +/// the one error decision tree β€” so no screen has to reimplement any of them. +/// +/// The Autoreceptive original guards calls with a *connectivity* check, which +/// suits an app talking to one public API. Peach talks to whichever box the +/// user configured β€” often on a LAN or VPN where the device has no internet at +/// all β€” so the equivalent guard here is [canReach]: an actual SSH handshake +/// with the target server. +class ParentViewModel extends ChangeNotifier { + OverlayEntry? loadingEntry; + + OverlayEntry? networkEntry; + + OverlayEntry? errorEntry; + + late DataManager dataManager; + + BuildContext context; + + OverlayState? overlayState; + + ParentViewModel(this.context) { + overlayState = Overlay.of(context); + dataManager = AppDataManager(InternalMemory(), SshService(), FileStorage()); + } + + DataManager getDataManager() { + return dataManager; + } + + // ── Loading overlay ──────────────────────────────────────────────────────── + + void showLoading(String loadingText) async { + if (loadingEntry != null) return; + _hideKeyboard(); + final jp = context.jp; + loadingEntry = OverlayEntry( + builder: (context) { + return Scaffold( + backgroundColor: jp.bg.withValues(alpha: 0.86), + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 64, + height: 64, + child: CircularProgressIndicator( + strokeWidth: 5, + color: colorPrimary, + backgroundColor: jp.surface2, + ), + ), + const SizedBox(height: 24), + text( + loadingText, + 18, + TextType.Bold, + color: jp.text, + align: TextAlign.center, + ), + const SizedBox(height: 8), + text( + 'Working on the server β€” keep this window open.', + 13, + TextType.Regular, + color: jp.textDim, + align: TextAlign.center, + ), + ], + ), + ), + ); + }, + ); + + overlayState?.insert(loadingEntry!); + notifyListeners(); + } + + void closeLoading() { + if (loadingEntry != null) { + loadingEntry?.remove(); + loadingEntry = null; + notifyListeners(); + } + } + + // ── Unreachable-server overlay ───────────────────────────────────────────── + + /// Shown when the target server can't be reached at all. [actions] is the + /// retry closure that re-runs whatever the user was doing. + void noNetwork(Function() actions) async { + if (networkEntry != null) return; + _hideKeyboard(); + final jp = context.jp; + networkEntry = OverlayEntry( + builder: (context) { + Widget hintRow(IconData icon, String label) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: colorPrimary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 16, color: colorPrimary), + ), + const SizedBox(width: 12), + Expanded( + child: text(label, 13, TextType.Regular, color: jp.textDim), + ), + ], + ), + ); + + return Scaffold( + backgroundColor: jp.bg, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 116, + height: 116, + decoration: BoxDecoration( + color: colorPrimary.withValues(alpha: 0.08), + shape: BoxShape.circle, + border: Border.all( + color: colorPrimary.withValues(alpha: 0.20), + width: 2, + ), + ), + child: Icon( + Icons.cloud_off_rounded, + size: 44, + color: colorPrimary, + ), + ), + const SizedBox(height: 28), + text( + "Server unreachable", + 22, + TextType.Bold, + color: jp.text, + align: TextAlign.center, + ), + const SizedBox(height: 10), + text( + "Peach couldn't open an SSH session to this server.", + 14, + TextType.Regular, + color: jp.textDim, + align: TextAlign.center, + ), + const SizedBox(height: 24), + Container( + decoration: BoxDecoration( + color: jp.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: jp.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + hintRow( + Icons.dns_outlined, + 'Check the host and port are correct', + ), + Divider(height: 1, color: jp.border), + hintRow( + Icons.vpn_key_outlined, + 'Confirm the username and password still work', + ), + Divider(height: 1, color: jp.border), + hintRow( + Icons.lan_outlined, + 'Make sure you are on the right network or VPN', + ), + ], + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: actions, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: text( + 'Try again', + 14, + TextType.Bold, + color: colorWhite, + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + + overlayState?.insert(networkEntry!); + notifyListeners(); + } + + void closeNetwork() { + if (networkEntry != null) { + networkEntry?.remove(); + networkEntry = null; + notifyListeners(); + } + } + + /// Guard every server-bound call with this: it opens (or reuses) an SSH + /// session to [server] and, on failure, raises the unreachable overlay wired + /// to [actions] and returns false so the caller can bail out early. + Future canReach(ServerProfile server, Function() actions) async { + closeNetwork(); + try { + await getDataManager().connect(server); + return true; + } catch (_) { + closeLoading(); + noNetwork(actions); + return false; + } + } + + // ── Error overlay ────────────────────────────────────────────────────────── + + void showError( + Function() actions, + Function() closeActions, + PeachError error, + String buttonText, + ) async { + if (errorEntry != null) return; + _hideKeyboard(); + final jp = context.jp; + final bool critical = error.severity == Severity.critical.name; + errorEntry = OverlayEntry( + builder: (context) { + return Scaffold( + backgroundColor: jp.bg, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: colorNegative.withValues(alpha: 0.10), + shape: BoxShape.circle, + border: Border.all( + color: colorNegative.withValues(alpha: 0.22), + width: 2, + ), + ), + child: Icon( + critical + ? Icons.gpp_maybe_rounded + : Icons.error_outline_rounded, + size: 40, + color: colorNegative, + ), + ), + ), + const SizedBox(height: 24), + text( + error.title, + 22, + TextType.Bold, + color: jp.text, + align: TextAlign.center, + ), + const SizedBox(height: 10), + text( + error.message, + 14, + TextType.Regular, + color: jp.textDim, + align: TextAlign.center, + height: 1.5, + ), + if (error.helper.isNotEmpty) ...[ + const SizedBox(height: 18), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: jp.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: jp.border), + ), + child: text( + error.helper, + 13, + TextType.Regular, + color: jp.textDim, + height: 1.55, + ), + ), + ], + const SizedBox(height: 12), + Center( + child: text( + 'Code ${error.code}', + 11, + TextType.Light, + color: jp.textFaint, + ), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () { + dismissError(); + actions(); + }, + child: text( + buttonText, + 14, + TextType.Bold, + color: colorWhite, + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () { + dismissError(); + closeActions(); + }, + child: text( + 'Dismiss', + 13, + TextType.Regular, + color: jp.textDim, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); + + overlayState?.insert(errorEntry!); + notifyListeners(); + } + + void dismissError() { + if (errorEntry != null) { + errorEntry?.remove(); + errorEntry = null; + notifyListeners(); + } + } + + /// The standard `catch` handler β€” every ViewModel routes failures here so the + /// same class of problem always reads the same way to the user. Closes the + /// loading overlay first, then maps the failure onto a [PeachError]. + void handleError( + Object? error, + Function() actions, + Function() closeAction, + String buttonName, + ) { + closeLoading(); + + if (error is SSHAuthAbortError || error is SSHAuthFailError) { + showError( + actions, + closeAction, + PeachError( + code: 4010.01, + message: "The server rejected these credentials.", + helper: + "Check the username and password on this server profile. If " + "the account uses a key or 2FA, SSH password auth may be " + "disabled on the host.", + title: "Authentication failed", + severity: Severity.critical.name, + ), + buttonName, + ); + } else if (error is SocketException || error is SSHStateError) { + showError( + actions, + closeAction, + PeachError( + code: 5000.01, + message: "Peach couldn't reach the server.", + helper: + "Confirm the host and port, that the machine is powered on, " + "and that you're on the network or VPN that can see it.", + title: "Connection failed", + severity: Severity.message.name, + ), + buttonName, + ); + } else if (error is TimeoutException) { + showError( + actions, + closeAction, + PeachError( + code: 5000.02, + message: "The server took too long to respond.", + helper: + "The connection is slow or the command is still running. " + "Retry, or check the server's load.", + title: "Timed out", + severity: Severity.message.name, + ), + buttonName, + ); + } else if (error is SSHError) { + showError( + actions, + closeAction, + PeachError( + code: 5100.00, + message: "The SSH session failed while running this operation.", + helper: error.toString(), + title: "SSH error", + severity: Severity.message.name, + ), + buttonName, + ); + } else if (error is FileSystemException) { + showError( + actions, + closeAction, + PeachError( + code: 6100.01, + message: "A local file couldn't be read or written.", + helper: + "Check the artifact path still exists and that Peach has " + "permission to read it.", + title: "File error", + severity: Severity.message.name, + ), + buttonName, + ); + } else if (error is Exception) { + showError( + actions, + closeAction, + PeachError( + code: 6000.01, + message: "An error occurred while processing your request.", + helper: error.toString(), + title: "Peach Error", + severity: Severity.message.name, + ), + buttonName, + ); + } else { + showError( + actions, + closeAction, + PeachError( + code: 8700.02, + message: "An error occurred while processing your request.", + helper: + "Relaunch Peach and try again. If the problem persists, " + "check the server logs for what happened.", + title: "Peach Error", + severity: Severity.message.name, + ), + buttonName, + ); + } + } + + // ── In-app notification sheet ────────────────────────────────────────────── + + /// A modal bottom sheet for confirmations and outcomes. [positiveAction] + /// must call `Navigator.pop(context)` first to dismiss the sheet before it + /// navigates anywhere. + Future showApplicationNotification( + NotificationType type, + String title, + String description, + bool enableDrag, + bool barrierDismiss, + VoidCallback? closeAction, { + String? action, + VoidCallback? positiveAction, + }) async { + final jp = context.jp; + + Color actionColor; + IconData actionIconData; + + switch (type) { + case NotificationType.success: + actionIconData = Icons.check_circle_rounded; + actionColor = colorPositive; + break; + case NotificationType.info: + actionIconData = Icons.info_rounded; + actionColor = colorAccent; + break; + case NotificationType.warning: + actionIconData = Icons.warning_amber_rounded; + actionColor = colorWarmYellow; + break; + case NotificationType.error: + actionIconData = Icons.error_rounded; + actionColor = colorNegative; + break; + } + + final bool hasAction = action != null && positiveAction != null; + + await showModalBottomSheet( + context: context, + barrierColor: colorPrimaryDark.withValues(alpha: 0.6), + backgroundColor: Colors.transparent, + elevation: 0, + enableDrag: enableDrag, + isDismissible: barrierDismiss, + isScrollControlled: true, + builder: (BuildContext context) { + return Container( + decoration: BoxDecoration( + color: jp.surface, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + border: Border.all(color: jp.border), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: jp.border, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: actionColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: actionColor.withValues(alpha: 0.2), + ), + ), + child: Icon( + actionIconData, + size: 28, + color: actionColor, + ), + ), + IconButton( + onPressed: () { + Navigator.pop(context); + closeAction?.call(); + }, + icon: Icon( + Icons.close_rounded, + size: 18, + color: jp.textDim, + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: actionColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: actionColor.withValues(alpha: 0.2), + ), + ), + child: text( + type.name.toUpperCase(), + 10, + TextType.Bold, + color: actionColor, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 12), + text(title, 20, TextType.Bold, color: jp.text), + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: actionColor.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: actionColor.withValues(alpha: 0.15), + ), + ), + child: text( + description, + 13, + TextType.Regular, + color: jp.textDim, + height: 1.6, + ), + ), + const SizedBox(height: 24), + if (hasAction) ...[ + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: positiveAction, + style: FilledButton.styleFrom( + backgroundColor: actionColor, + ), + icon: Icon(actionIconData, size: 16), + label: text( + action, + 14, + TextType.Bold, + color: colorWhite, + ), + ), + ), + const SizedBox(height: 12), + ], + Center( + child: TextButton( + onPressed: () { + Navigator.pop(context); + closeAction?.call(); + }, + child: text( + hasAction ? "Cancel" : "Got it, dismiss", + 12, + TextType.Regular, + color: jp.textDim, + ), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } + + void _hideKeyboard() { + SystemChannels.textInput.invokeMethod('TextInput.hide'); + } + + @override + void dispose() { + closeLoading(); + closeNetwork(); + dismissError(); + super.dispose(); + } +} diff --git a/lib/Peach/see/redis/ConnectRedis.dart b/lib/Peach/see/redis/ConnectRedis.dart new file mode 100644 index 0000000..93ebac6 --- /dev/null +++ b/lib/Peach/see/redis/ConnectRedis.dart @@ -0,0 +1,36 @@ +import '../../about/external/data/Redis.dart'; + +/// One scan's worth of results β€” the key list plus the context needed to +/// label it (per-database counts, DBSIZE, and per-key types when filtering). +class RedisScan { + final int databaseCount; + final Map databaseCounts; + final int total; + final List keys; + final Map keyTypes; + + const RedisScan({ + required this.databaseCount, + required this.databaseCounts, + required this.total, + required this.keys, + required this.keyTypes, + }); +} + +abstract class ConnectRedis { + /// A scan completed. + void onScanned(RedisScan scan); + + /// The scan couldn't run; [message] is already phrased for the user. + void onScanFailed(String message); + + /// A password was read out of the server's redis.conf. + void onPasswordDetected(String password); + + /// No `requirepass` was present in redis.conf. + void onPasswordMissing(); + + /// One key's value was read. + void onValueLoaded(RedisEntry entry); +} diff --git a/lib/Peach/see/redis/Redis.dart b/lib/Peach/see/redis/Redis.dart new file mode 100644 index 0000000..34b7781 --- /dev/null +++ b/lib/Peach/see/redis/Redis.dart @@ -0,0 +1,14 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import 'RedisState.dart'; + +/// Browse and read Redis keys/values on the server via `redis-cli` over SSH. +class Redis extends StatefulWidget { + final ServerProfile server; + + const Redis({super.key, required this.server}); + + @override + State createState() => RedisState(); +} diff --git a/lib/Peach/see/redis/RedisState.dart b/lib/Peach/see/redis/RedisState.dart new file mode 100644 index 0000000..782b3f9 --- /dev/null +++ b/lib/Peach/see/redis/RedisState.dart @@ -0,0 +1,792 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/data/Redis.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import 'ConnectRedis.dart'; +import 'Redis.dart'; +import 'ViewRedis.dart'; + +class RedisState extends State implements ConnectRedis { + static const _types = [ + 'all', + 'string', + 'list', + 'set', + 'hash', + 'zset', + 'stream', + ]; + + ViewRedis? _model; + + final _host = TextEditingController(text: '127.0.0.1'); + final _port = TextEditingController(text: '6379'); + final _password = TextEditingController(); + final _pattern = TextEditingController(text: '*'); + + int _selectedDb = 0; + int _dbCount = 16; + Map _dbCounts = {}; + String _typeFilter = 'all'; + + List _keys = []; + Map _keyTypes = {}; + int _total = 0; + bool _loading = true; + bool _detecting = false; + bool _connected = false; + String? _error; + + String? _selectedKey; + RedisEntry? _entry; + bool _loadingValue = false; + bool _wide = false; + + ServerProfile get _server => widget.server; + + int get _portNumber => int.tryParse(_port.text.trim()) ?? 6379; + + String get _hostText => + _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(); + + @override + void dispose() { + _host.dispose(); + _port.dispose(); + _password.dispose(); + _pattern.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewRedis(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + 'Redis Β· ${_server.name}', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + IconButton( + tooltip: 'Refresh', + onPressed: _loading ? null : _onScan, + icon: const Icon(Icons.refresh), + ), + const SizedBox(width: 4), + ], + ), + body: Column( + children: [ + _controls(), + if (_loading) const LinearProgressIndicator(minHeight: 3), + if (_error != null) _errorBar(), + Expanded( + child: LayoutBuilder( + builder: (context, c) { + _wide = c.maxWidth >= 760; + if (!_wide) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: _keyList(), + ); + } + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 4, child: _keyList()), + const SizedBox(width: 16), + Expanded( + flex: 5, + child: _entry == null && !_loadingValue + ? _placeholder() + : _valuePanel(_entry), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + void _onReady(ViewRedis model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _scan(); + } + + void _scan({bool triedDetect = false}) { + setState(() { + _loading = true; + _error = null; + }); + _model?.scan( + _server, + host: _hostText, + port: _portNumber, + password: _password.text, + database: _selectedDb, + pattern: _pattern.text, + typeFilter: _typeFilter, + triedDetect: triedDetect, + ); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onScan() => _scan(); + + void _onPatternSubmitted(String _) => _scan(); + + void _onDetectPassword() { + setState(() => _detecting = true); + _model?.detectPassword(_server); + } + + void _onSelectDatabase(int db) { + if (_selectedDb == db) return; + setState(() => _selectedDb = db); + _scan(); + } + + void _onSelectType(String type) { + if (_typeFilter == type) return; + setState(() => _typeFilter = type); + _scan(); + } + + void _onSelectKey(String key) { + setState(() { + _selectedKey = key; + _entry = null; + _loadingValue = true; + }); + _model?.loadValue( + _server, + key, + host: _hostText, + port: _portNumber, + password: _password.text, + database: _selectedDb, + ); + } + + void _onCopyValue(RedisEntry entry) { + Clipboard.setData(ClipboardData(text: entry.value)); + _notify('Value copied.'); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + void _showValueSheet(RedisEntry entry) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: context.jp.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(sheetContext).viewInsets.bottom, + ), + child: SizedBox( + height: MediaQuery.of(sheetContext).size.height * 0.7, + child: Padding( + padding: const EdgeInsets.all(16), + child: _valuePanel(entry, inSheet: true), + ), + ), + ), + ); + } + + // ── ConnectRedis ─────────────────────────────────────────────────────────── + + @override + void onScanned(RedisScan scan) { + if (!mounted) return; + setState(() { + _connected = true; + _dbCount = scan.databaseCount; + _dbCounts = scan.databaseCounts; + _total = scan.total; + _keys = scan.keys; + _keyTypes = scan.keyTypes; + _loading = false; + _error = null; + }); + } + + @override + void onScanFailed(String message) { + if (!mounted) return; + setState(() { + _connected = false; + _loading = false; + _error = message; + _keys = []; + }); + } + + @override + void onPasswordDetected(String password) { + if (!mounted) return; + setState(() { + _password.text = password; + _detecting = false; + }); + _notify('Found the Redis password in redis.conf β€” connecting…'); + // triedDetect stops a second lookup if this password is also rejected. + _scan(triedDetect: true); + } + + @override + void onPasswordMissing() { + if (!mounted) return; + setState(() => _detecting = false); + _notify('No requirepass found in redis.conf.'); + } + + @override + void onValueLoaded(RedisEntry entry) { + if (!mounted) return; + setState(() { + _entry = entry; + _loadingValue = false; + }); + if (!_wide) _showValueSheet(entry); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _controls() { + final jp = context.jp; + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + decoration: BoxDecoration( + color: jp.surface, + border: Border(bottom: BorderSide(color: jp.border)), + ), + child: Column( + children: [ + Row( + children: [ + Expanded( + flex: 3, + child: TextField( + controller: _host, + decoration: const InputDecoration( + labelText: 'Host', + isDense: true, + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 80, + child: TextField( + controller: _port, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Port', + isDense: true, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password (requirepass)', + isDense: true, + ), + ), + ), + const SizedBox(width: 10), + OutlinedButton.icon( + onPressed: _detecting ? null : _onDetectPassword, + icon: _detecting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.key, size: 18), + label: text('Detect', 13, TextType.Bold, color: jp.textDim), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: TextField( + controller: _pattern, + onSubmitted: _onPatternSubmitted, + decoration: const InputDecoration( + labelText: 'Key pattern', + hintText: 'user:* Β· *', + prefixIcon: Icon(Icons.search, size: 20), + isDense: true, + ), + ), + ), + const SizedBox(width: 10), + FilledButton.icon( + onPressed: _loading ? null : _onScan, + icon: const Icon(Icons.travel_explore, size: 18), + label: text('Scan', 13, TextType.Bold, color: colorWhite), + ), + ], + ), + const SizedBox(height: 12), + _dbSelector(), + const SizedBox(height: 10), + _typeSelector(), + ], + ), + ); + } + + Widget _dbSelector() { + final jp = context.jp; + return Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _selectorLabel(Icons.storage, 'Database'), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: List.generate(_dbCount, (i) { + final selected = _selectedDb == i; + final count = _dbCounts[i] ?? 0; + return InkWell( + borderRadius: BorderRadius.circular(9), + onTap: _loading ? null : () => _onSelectDatabase(i), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 11, + vertical: 6, + ), + decoration: BoxDecoration( + color: selected + ? PeachTheme.seed.withValues(alpha: 0.14) + : jp.surface2, + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: selected + ? PeachTheme.seed.withValues(alpha: 0.7) + : jp.border, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + text( + 'db$i', + 12.5, + selected ? TextType.Bold : TextType.Regular, + color: selected ? PeachTheme.seed : jp.text, + ), + if (count > 0) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1, + ), + decoration: BoxDecoration( + color: selected + ? PeachTheme.seed.withValues(alpha: 0.22) + : jp.border.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(6), + ), + child: text( + '$count', + 10.5, + TextType.Bold, + color: selected ? PeachTheme.seed : jp.textFaint, + ), + ), + ], + ], + ), + ), + ); + }), + ), + ], + ), + ); + } + + Widget _typeSelector() { + final jp = context.jp; + return Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _selectorLabel(Icons.category_outlined, 'Type filter'), + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: _types.map((type) { + final selected = _typeFilter == type; + final color = type == 'all' ? PeachTheme.slate : _typeColor(type); + return InkWell( + borderRadius: BorderRadius.circular(9), + onTap: _loading ? null : () => _onSelectType(type), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 11, + vertical: 6, + ), + decoration: BoxDecoration( + color: selected + ? color.withValues(alpha: 0.14) + : jp.surface2, + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: selected + ? color.withValues(alpha: 0.7) + : jp.border, + ), + ), + child: text( + type == 'all' ? 'all types' : type, + 12.5, + selected ? TextType.Bold : TextType.Regular, + color: selected ? color : jp.text, + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } + + Widget _selectorLabel(IconData icon, String label) => Row( + children: [ + Icon(icon, size: 14, color: context.jp.textFaint), + const SizedBox(width: 6), + text( + label, + 11, + TextType.Bold, + color: context.jp.textFaint, + letterSpacing: 0.4, + ), + ], + ); + + Color _typeColor(String type) { + switch (type) { + case 'string': + return PeachTheme.accent; + case 'list': + return PeachTheme.warn; + case 'set': + return PeachTheme.violet; + case 'hash': + return PeachTheme.seed; + case 'zset': + return PeachTheme.mint; + case 'stream': + return PeachTheme.pink; + default: + return PeachTheme.slate; + } + } + + Widget _errorBar() => Container( + width: double.infinity, + margin: const EdgeInsets.fromLTRB(16, 12, 16, 0), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: PeachTheme.danger.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: PeachTheme.danger.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + const Icon(Icons.error_outline, color: PeachTheme.danger), + const SizedBox(width: 10), + Expanded( + child: text(_error!, 14, TextType.Regular, color: PeachTheme.danger), + ), + ], + ), + ); + + Widget _keyList() { + final jp = context.jp; + return Container( + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 10), + child: Row( + children: [ + const Icon(Icons.key, size: 16, color: PeachTheme.seed), + const SizedBox(width: 8), + text( + 'KEYS', + 12, + TextType.Bold, + color: jp.textDim, + letterSpacing: 1, + ), + const SizedBox(width: 8), + text( + '${_keys.length}', + 12, + TextType.Bold, + color: PeachTheme.seed, + ), + if (_total > 0) ...[ + const SizedBox(width: 4), + text( + _pattern.text.trim() == '*' + ? 'of $_total' + : '($_total total)', + 11.5, + TextType.Regular, + color: jp.textFaint, + ), + ], + if (_keys.length >= 100000) ...[ + const SizedBox(width: 6), + text('(capped)', 11, TextType.Regular, color: jp.textFaint), + ], + ], + ), + ), + Divider(height: 1, color: jp.border), + Expanded( + child: _keys.isEmpty + ? Center( + child: text( + _loading + ? 'Scanning…' + : (_connected ? 'No keys match.' : 'Not connected.'), + 14, + TextType.Regular, + color: jp.textFaint, + ), + ) + : ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: _keys.length, + separatorBuilder: (_, i) => Divider( + height: 1, + color: jp.border, + indent: 12, + endIndent: 12, + ), + itemBuilder: (_, i) => _keyRow(_keys[i]), + ), + ), + ], + ), + ); + } + + Widget _keyRow(String key) { + final jp = context.jp; + final selected = _wide && key == _selectedKey; + final type = _keyTypes[key]; + return InkWell( + onTap: () => _onSelectKey(key), + child: Container( + color: selected ? PeachTheme.seed.withValues(alpha: 0.10) : null, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + child: Row( + children: [ + if (type != null) ...[_typeBadge(type), const SizedBox(width: 9)], + Expanded( + child: text( + key, + 13, + TextType.Regular, + color: selected ? PeachTheme.seed : jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(Icons.chevron_right, size: 18, color: jp.textFaint), + ], + ), + ), + ); + } + + Widget _typeBadge(String type) { + final color = _typeColor(type); + return Container( + width: 46, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: text(type, 9.5, TextType.Bold, color: color, letterSpacing: 0.2), + ); + } + + Widget _placeholder() { + final jp = context.jp; + return Container( + decoration: PeachTheme.panel(context, radius: 16), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.data_object, size: 40, color: jp.textFaint), + const SizedBox(height: 10), + text( + 'Select a key to view its value', + 14, + TextType.Regular, + color: jp.textDim, + ), + ], + ), + ), + ); + } + + Widget _valuePanel(RedisEntry? entry, {bool inSheet = false}) { + final jp = context.jp; + if (_loadingValue) { + return Container( + decoration: inSheet ? null : PeachTheme.panel(context, radius: 16), + child: const Center(child: CircularProgressIndicator()), + ); + } + if (entry == null) return _placeholder(); + + final content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: text( + entry.key, + 14, + TextType.Bold, + color: jp.text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + tooltip: 'Copy value', + onPressed: () => _onCopyValue(entry), + icon: const Icon(Icons.copy_all, size: 18), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _tag(entry.type.toUpperCase(), PeachTheme.accent), + const SizedBox(width: 8), + _tag('TTL ${entry.ttlLabel}', jp.textDim), + ], + ), + const SizedBox(height: 12), + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: jp.consoleBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: ConsoleColors.frame), + ), + child: SingleChildScrollView( + child: SelectableText( + entry.value.isEmpty ? '(empty)' : entry.value, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.4, + color: ConsoleColors.body, + ), + ), + ), + ), + ), + ], + ); + + if (inSheet) return content; + return Container( + padding: const EdgeInsets.all(16), + decoration: PeachTheme.panel(context, radius: 16), + child: content, + ); + } + + Widget _tag(String label, Color color) => Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(20), + ), + child: text(label, 11, TextType.Bold, color: color), + ); +} diff --git a/lib/Peach/see/redis/ViewRedis.dart b/lib/Peach/see/redis/ViewRedis.dart new file mode 100644 index 0000000..180f485 --- /dev/null +++ b/lib/Peach/see/redis/ViewRedis.dart @@ -0,0 +1,177 @@ +import '../../about/external/data/Redis.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../parent/ParentViewModel.dart'; +import 'ConnectRedis.dart'; + +class ViewRedis extends ParentViewModel { + ConnectRedis connection; + + ViewRedis(super.context, this.connection); + + /// Pings Redis, then reads the database list, the current DB's size and the + /// keys matching [pattern]. On a NOAUTH bounce with no password entered, it + /// reads `requirepass` out of redis.conf once and retries β€” the common case + /// where the user has SSH access but hasn't been told the Redis password. + void scan( + ServerProfile server, { + required String host, + required int port, + required String password, + required int database, + required String pattern, + required String typeFilter, + bool triedDetect = false, + }) async { + if (!await canReach( + server, + () => scan( + server, + host: host, + port: port, + password: password, + database: database, + pattern: pattern, + typeFilter: typeFilter, + triedDetect: triedDetect, + ), + )) { + return; + } + try { + final (ok, detail) = await getDataManager().redisPing( + server, + host: host, + port: port, + pass: password, + db: database, + ); + + if (!ok) { + if (!triedDetect && + detail.toUpperCase().contains('NOAUTH') && + password.trim().isEmpty) { + final found = await getDataManager().redisConfigPassword(server); + if (found.isNotEmpty) { + connection.onPasswordDetected(found); + return; + } + } + connection.onScanFailed(_hint(detail, host, port)); + return; + } + + final effectivePattern = pattern.trim().isEmpty ? '*' : pattern.trim(); + + final (databaseCount, databaseCounts) = await getDataManager() + .redisDatabases(server, host: host, port: port, pass: password); + final total = await getDataManager().redisDbSize( + server, + host: host, + port: port, + pass: password, + db: database, + ); + + final keys = []; + final keyTypes = {}; + + if (typeFilter == 'all') { + keys.addAll( + await getDataManager().redisKeys( + server, + pattern: effectivePattern, + host: host, + port: port, + pass: password, + db: database, + ), + ); + } else { + final typed = await getDataManager().redisKeysTyped( + server, + pattern: effectivePattern, + filterType: typeFilter, + host: host, + port: port, + pass: password, + db: database, + ); + for (final (key, type) in typed) { + keys.add(key); + keyTypes[key] = type; + } + } + + connection.onScanned( + RedisScan( + databaseCount: databaseCount, + databaseCounts: databaseCounts, + total: total, + keys: keys, + keyTypes: keyTypes, + ), + ); + } catch (error) { + connection.onScanFailed(error.toString()); + } + } + + /// Reads `requirepass` from the server's redis.conf. + void detectPassword(ServerProfile server) async { + if (!await canReach(server, () => detectPassword(server))) return; + try { + final found = await getDataManager().redisConfigPassword(server); + if (found.isEmpty) { + connection.onPasswordMissing(); + } else { + connection.onPasswordDetected(found); + } + } catch (error) { + handleError(error, () => detectPassword(server), () {}, "Retry"); + } + } + + /// Reads one key. A failure is surfaced as an `error`-typed entry rather + /// than an overlay β€” the value panel is the natural place to show it. + void loadValue( + ServerProfile server, + String key, { + required String host, + required int port, + required String password, + required int database, + }) async { + try { + final entry = await getDataManager().redisGet( + server, + key, + host: host, + port: port, + pass: password, + db: database, + ); + connection.onValueLoaded(entry); + } catch (error) { + connection.onValueLoaded( + RedisEntry(key: key, type: 'error', ttl: '', value: '$error'), + ); + } + } + + /// Turns a raw redis-cli complaint into something the user can act on. + String _hint(String detail, String host, int port) { + final message = detail.isEmpty ? 'Redis did not respond.' : detail; + if (message.toUpperCase().contains('NOAUTH')) { + return 'Redis requires a password. Enter it below (or tap Detect to read ' + 'it from redis.conf), then Scan.'; + } + if (message.toLowerCase().contains('wrongpass')) { + return 'Wrong Redis password. Fix it and Scan again.'; + } + if (message.toLowerCase().contains('connection refused')) { + return 'Nothing is listening on $host:$port. Check the host/port ' + '(Redis in Docker often maps a different port).'; + } + return message; + } +} diff --git a/lib/Peach/see/server/detail/ConnectServerDetail.dart b/lib/Peach/see/server/detail/ConnectServerDetail.dart new file mode 100644 index 0000000..94c7415 --- /dev/null +++ b/lib/Peach/see/server/detail/ConnectServerDetail.dart @@ -0,0 +1,10 @@ +import '../../../about/internal/application/DeploymentRecord.dart'; +import '../../../about/internal/application/ServerProfile.dart'; + +abstract class ConnectServerDetail { + /// The stored copy of this server, re-read after any change. + void onServerLoaded(ServerProfile server, List history); + + /// The server (and its stored password) was removed. + void onServerDeleted(); +} diff --git a/lib/Peach/see/server/detail/ServerDetail.dart b/lib/Peach/see/server/detail/ServerDetail.dart new file mode 100644 index 0000000..0173e2a --- /dev/null +++ b/lib/Peach/see/server/detail/ServerDetail.dart @@ -0,0 +1,20 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../about/internal/application/DeploymentRecord.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import 'ServerDetailState.dart'; + +/// A single server: its connection, its tools, and the services on it. +class ServerDetail extends StatefulWidget { + final ServerProfile server; + final List history; + + const ServerDetail({ + super.key, + required this.server, + this.history = const [], + }); + + @override + State createState() => ServerDetailState(); +} diff --git a/lib/Peach/see/server/detail/ServerDetailState.dart b/lib/Peach/see/server/detail/ServerDetailState.dart new file mode 100644 index 0000000..53ec74d --- /dev/null +++ b/lib/Peach/see/server/detail/ServerDetailState.dart @@ -0,0 +1,591 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../about/internal/application/DeploymentRecord.dart'; +import '../../../about/internal/application/NavigatorType.dart'; +import '../../../about/internal/application/NotificationType.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/ServiceConfig.dart'; +import '../../../about/internal/application/TextType.dart'; +import '../../../configs/Navigator.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import '../../configuration/Configuration.dart'; +import '../../deploy/batch/BatchDeploy.dart'; +import '../../deploy/single/Deploy.dart'; +import '../../docker/manager/DockerManager.dart'; +import '../../files/Files.dart'; +import '../../health/Health.dart'; +import '../../kafka/Kafka.dart'; +import '../../logs/Logs.dart'; +import '../../redis/Redis.dart'; +import '../../service/ServiceEditor.dart'; +import '../../systemd/Systemd.dart'; +import '../editor/ServerEditor.dart'; +import 'ConnectServerDetail.dart'; +import 'ServerDetail.dart'; +import 'ViewServerDetail.dart'; + +class ServerDetailState extends State + implements ConnectServerDetail { + ViewServerDetail? _model; + + late ServerProfile _server; + List _history = []; + + @override + void initState() { + super.initState(); + _server = widget.server.copy(); + _history = widget.history; + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewServerDetail(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text(_server.name, 20, TextType.Bold, color: context.jp.text), + actions: [ + IconButton( + tooltip: 'Edit server', + onPressed: _onEditServer, + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Delete server', + onPressed: _onDeleteServer, + icon: const Icon(Icons.delete_outline), + ), + const SizedBox(width: 4), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _onAddService, + icon: const Icon(Icons.add), + label: text('Add service', 14, TextType.Bold, color: colorWhite), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + _connectionCard(), + const SizedBox(height: 18), + _toolsSection(), + const SizedBox(height: 20), + _sectionHeader(), + const SizedBox(height: 10), + if (_server.services.isEmpty) + _noServices() + else + LayoutBuilder( + builder: (context, c) { + const gap = 12.0; + final cols = c.maxWidth >= 760 ? 2 : 1; + final w = (c.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + for (final svc in _server.services) + SizedBox(width: w, child: _serviceCard(svc)), + ], + ); + }, + ), + ], + ), + ), + ); + } + + void _onReady(ViewServerDetail model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _model?.loadServer(_server.id); + } + + // ── named handlers: server ───────────────────────────────────────────────── + + Future _onEditServer() async { + final edited = await PeachNavigation().navigateToPageWithData( + ServerEditor(server: _server), + context, + ); + if (edited is! ServerProfile) return; + // Preserve the services list on the edited connection. + edited.services = _server.services; + _model?.saveServer(edited); + } + + void _onDeleteServer() { + _model?.showApplicationNotification( + NotificationType.warning, + 'Delete "${_server.name}"?', + 'Removes the server, its services and its stored password.', + true, + true, + null, + action: 'Delete server', + positiveAction: _onConfirmDeleteServer, + ); + } + + void _onConfirmDeleteServer() { + Navigator.pop(context); + _model?.deleteServer(_server); + } + + // ── named handlers: services ─────────────────────────────────────────────── + + Future _onAddService() async { + final created = await PeachNavigation().navigateToPageWithData( + const ServiceEditor(), + context, + ); + if (created is! ServiceConfig) return; + _model?.addService(_server, created); + } + + Future _onEditService(ServiceConfig svc) async { + final edited = await PeachNavigation().navigateToPageWithData( + ServiceEditor(service: svc), + context, + ); + if (edited is! ServiceConfig) return; + _model?.updateService(_server, edited); + } + + void _onDeleteService(ServiceConfig svc) => + _model?.removeService(_server, svc); + + Future _onDeploy(ServiceConfig svc) async { + await PeachNavigation().navigateToPageWithData( + Deploy(server: _server, service: svc), + context, + ); + _model?.loadServer(_server.id); + } + + void _onViewLogs(ServiceConfig svc) { + PeachNavigation().navigateToPage( + NavigatorType.justOpen, + Logs(server: _server, service: svc), + context, + ); + } + + Future _onRegisterUnit(ServiceConfig? svc) async { + await PeachNavigation().navigateToPageWithData( + Systemd(server: _server, service: svc), + context, + ); + _model?.loadServer(_server.id); + } + + // ── named handlers: tools ────────────────────────────────────────────────── + + Future _onOpenBatch() async { + await PeachNavigation().navigateToPageWithData( + BatchDeploy(server: _server), + context, + ); + _model?.loadServer(_server.id); + } + + void _onOpenConfiguration() => _open(Configuration(server: _server)); + + void _onOpenHealth() => _open(Health(servers: [_server])); + + void _onOpenFiles() => _open(Files(server: _server)); + + void _onOpenDocker() => _open(DockerManager(server: _server)); + + void _onOpenRedis() => _open(Redis(server: _server)); + + void _onOpenKafka() => _open(Kafka(server: _server)); + + void _onNewUnit() => _onRegisterUnit(null); + + void _open(Widget destination) => PeachNavigation().navigateToPage( + NavigatorType.justOpen, + destination, + context, + ); + + // ── ConnectServerDetail ──────────────────────────────────────────────────── + + @override + void onServerLoaded(ServerProfile server, List history) { + if (!mounted) return; + setState(() { + _server = server; + _history = history; + }); + } + + @override + void onServerDeleted() { + if (!mounted) return; + Navigator.pop(context, true); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _connectionCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _line( + Icons.dns, + '${_server.username}@${_server.host}:${_server.port}', + ), + const SizedBox(height: 8), + _line( + _server.useSudo ? Icons.shield : Icons.shield_outlined, + _server.useSudo ? 'sudo enabled' : 'no sudo', + ), + const SizedBox(height: 8), + _line(Icons.lock, 'password stored encrypted'), + ], + ), + ); + } + + Widget _line(IconData icon, String label) => Row( + children: [ + Icon(icon, size: 15, color: PeachTheme.seed.withValues(alpha: 0.9)), + const SizedBox(width: 8), + Expanded( + child: text(label, 13, TextType.Regular, color: context.jp.textDim), + ), + ], + ); + + Widget _toolsSection() { + return LayoutBuilder( + builder: (context, c) { + const gap = 12.0; + final cols = c.maxWidth >= 520 ? 4 : 2; + final w = (c.maxWidth - gap * (cols - 1)) / cols; + return Wrap( + spacing: gap, + runSpacing: gap, + children: [ + _tool( + w, + Icons.rocket_launch, + 'Build & deploy all', + 'Batch build + deploy', + PeachTheme.seed, + _onOpenBatch, + ), + _tool( + w, + Icons.fact_check_outlined, + 'Configuration', + 'Environment & setup', + PeachTheme.warn, + _onOpenConfiguration, + ), + _tool( + w, + Icons.monitor_heart, + 'Health', + 'Service status', + PeachTheme.danger, + _onOpenHealth, + ), + _tool( + w, + Icons.folder_open, + 'Files', + 'Browse / transfer', + PeachTheme.accent, + _onOpenFiles, + ), + _tool( + w, + Icons.inventory_2, + 'Docker', + 'Containers & images', + PeachTheme.accent, + _onOpenDocker, + ), + _tool( + w, + Icons.data_object, + 'Redis', + 'Browse keys / values', + PeachTheme.warn, + _onOpenRedis, + ), + _tool( + w, + Icons.hub, + 'Kafka', + 'Topics Β· groups Β· lag', + PeachTheme.violet, + _onOpenKafka, + ), + _tool( + w, + Icons.miscellaneous_services, + 'New unit', + 'systemd wizard', + PeachTheme.violet, + _onNewUnit, + ), + ], + ); + }, + ); + } + + Widget _tool( + double w, + IconData icon, + String title, + String sub, + Color color, + VoidCallback onTap, + ) { + final jp = context.jp; + return SizedBox( + width: w, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(11), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(height: 12), + text( + title, + 14, + TextType.Bold, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 1), + text( + sub, + 11.5, + TextType.Regular, + color: jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } + + Widget _sectionHeader() => Row( + children: [ + text( + 'SERVICES', + 12, + TextType.Bold, + color: context.jp.textDim, + letterSpacing: 1.2, + ), + const SizedBox(width: 8), + text( + '${_server.services.length}', + 12, + TextType.Bold, + color: PeachTheme.seed, + ), + ], + ); + + Widget _serviceCard(ServiceConfig svc) { + final jp = context.jp; + final count = _history.where((h) => h.serviceId == svc.id).length; + return Container( + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: (svc.isWeb ? PeachTheme.accent : PeachTheme.seed) + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(9), + ), + child: Icon( + svc.isWeb + ? Icons.language + : (svc.isGeneric + ? Icons.terminal + : Icons.settings_suggest), + color: svc.isWeb ? PeachTheme.accent : PeachTheme.seed, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + svc.name, + 15, + TextType.Regular, + color: jp.text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + text( + svc.isWeb + ? svc.remoteRoot + : svc.remotePath( + svc.remoteJarName.isEmpty + ? '' + : svc.remoteJarName, + ), + 12, + TextType.Regular, + color: jp.textDim, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (!svc.isWeb) + IconButton( + onPressed: () => _onRegisterUnit(svc), + icon: const Icon( + Icons.settings_applications_outlined, + size: 20, + ), + tooltip: 'Register systemd unit', + ), + IconButton( + onPressed: () => _onEditService(svc), + icon: const Icon(Icons.edit_outlined, size: 20), + tooltip: 'Edit', + ), + IconButton( + onPressed: () => _onDeleteService(svc), + icon: const Icon(Icons.delete_outline, size: 20), + tooltip: 'Delete', + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + if (svc.isWeb) + _tag('β—† ${svc.framework.label}') + else if (svc.serviceName.isNotEmpty) + _tag('unit: ${svc.serviceName}'), + if (svc.canBuild) _tag('βš™ builds from source'), + if (count > 0) _tag('$count deploys'), + ], + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => _onViewLogs(svc), + icon: const Icon(Icons.article_outlined, size: 18), + label: text('Logs', 13, TextType.Bold, color: jp.textDim), + style: OutlinedButton.styleFrom( + foregroundColor: jp.textDim, + side: BorderSide(color: jp.border), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: () => _onDeploy(svc), + icon: const Icon(Icons.rocket_launch, size: 18), + label: text('Deploy', 13, TextType.Bold, color: colorWhite), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _tag(String label) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: context.jp.surface2, + borderRadius: BorderRadius.circular(20), + ), + child: text(label, 11, TextType.Regular, color: context.jp.textDim), + ); + + Widget _noServices() => Container( + padding: const EdgeInsets.symmetric(vertical: 30), + alignment: Alignment.center, + child: Column( + children: [ + Icon( + Icons.settings_suggest, + size: 40, + color: PeachTheme.seed.withValues(alpha: 0.5), + ), + const SizedBox(height: 10), + text('No services yet', 14, TextType.Bold, color: context.jp.text), + const SizedBox(height: 4), + text( + 'Add a service to deploy a jar to this server.', + 13, + TextType.Regular, + color: context.jp.textDim, + ), + ], + ), + ); +} diff --git a/lib/Peach/see/server/detail/ViewServerDetail.dart b/lib/Peach/see/server/detail/ViewServerDetail.dart new file mode 100644 index 0000000..388d24f --- /dev/null +++ b/lib/Peach/see/server/detail/ViewServerDetail.dart @@ -0,0 +1,87 @@ +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/ServiceConfig.dart'; +import '../../parent/ParentViewModel.dart'; +import 'ConnectServerDetail.dart'; + +class ViewServerDetail extends ParentViewModel { + ConnectServerDetail connection; + + ViewServerDetail(super.context, this.connection); + + /// Re-reads this server from storage along with the deployment history, so + /// the screen always reflects what was actually persisted. + void loadServer(String serverId) async { + try { + final servers = await getDataManager().getServers(); + final history = await getDataManager().getDeployments(); + final stored = servers.where((s) => s.id == serverId).firstOrNull; + if (stored == null) { + connection.onServerDeleted(); + return; + } + connection.onServerLoaded(stored, history); + } catch (error) { + handleError(error, () => loadServer(serverId), () {}, "Retry"); + } + } + + /// Persists [server] β€” used after editing the connection, or after adding, + /// editing or removing one of its services. + void saveServer(ServerProfile server) async { + try { + final servers = await getDataManager().getServers(); + final index = servers.indexWhere((s) => s.id == server.id); + if (index >= 0) { + servers[index] = server; + } else { + servers.add(server); + } + await getDataManager().setServers(servers); + loadServer(server.id); + } catch (error) { + handleError(error, () => saveServer(server), () {}, "Retry"); + } + } + + /// Adds [service] to [server] and persists. + void addService(ServerProfile server, ServiceConfig service) { + server.services.add(service); + saveServer(server); + } + + /// Replaces the stored copy of [service] on [server]. + void updateService(ServerProfile server, ServiceConfig service) { + final index = server.services.indexWhere((s) => s.id == service.id); + if (index >= 0) { + server.services[index] = service; + } else { + server.services.add(service); + } + saveServer(server); + } + + void removeService(ServerProfile server, ServiceConfig service) { + server.services.removeWhere((s) => s.id == service.id); + saveServer(server); + } + + /// Saves the order services are deployed in during a batch run. + void saveDeployOrder(ServerProfile server, List ids) { + server.deployOrder = ids; + saveServer(server); + } + + void deleteServer(ServerProfile server) async { + showLoading("Removing ${server.name} …"); + try { + final servers = await getDataManager().getServers(); + servers.removeWhere((s) => s.id == server.id); + await getDataManager().setServers(servers); + await getDataManager().deleteServerSecret(server.id); + closeLoading(); + connection.onServerDeleted(); + } catch (error) { + handleError(error, () => deleteServer(server), () {}, "Retry"); + } + } +} diff --git a/lib/Peach/see/server/editor/ConnectServerEditor.dart b/lib/Peach/see/server/editor/ConnectServerEditor.dart new file mode 100644 index 0000000..274fbb0 --- /dev/null +++ b/lib/Peach/see/server/editor/ConnectServerEditor.dart @@ -0,0 +1,6 @@ +import '../../../about/internal/application/ServerProfile.dart'; + +abstract class ConnectServerEditor { + /// The connection details were verified against the live server. + void onConnectionVerified(ServerProfile server); +} diff --git a/lib/Peach/see/server/editor/ServerEditor.dart b/lib/Peach/see/server/editor/ServerEditor.dart new file mode 100644 index 0000000..55aa3e0 --- /dev/null +++ b/lib/Peach/see/server/editor/ServerEditor.dart @@ -0,0 +1,16 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import 'ServerEditorState.dart'; + +/// Edits a server's connection details only. Services are managed on the +/// server detail screen. +class ServerEditor extends StatefulWidget { + /// null = register a new server. + final ServerProfile? server; + + const ServerEditor({super.key, this.server}); + + @override + State createState() => ServerEditorState(); +} diff --git a/lib/Peach/see/server/editor/ServerEditorState.dart b/lib/Peach/see/server/editor/ServerEditorState.dart new file mode 100644 index 0000000..fecc2be --- /dev/null +++ b/lib/Peach/see/server/editor/ServerEditorState.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/TextType.dart'; +import '../../../designs/input/InputFields.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import '../../../utils/Validators.dart'; +import 'ConnectServerEditor.dart'; +import 'ServerEditor.dart'; +import 'ViewServerEditor.dart'; + +class ServerEditorState extends State + implements ConnectServerEditor { + ViewServerEditor? _model; + + final _formKey = GlobalKey(); + late final ServerProfile _server; + bool _showPassword = false; + + @override + void initState() { + super.initState(); + _server = widget.server?.copy() ?? ServerProfile(); + } + + bool get _isNew => widget.server == null; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewServerEditor(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + _isNew ? 'Register server' : 'Edit server', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + TextButton.icon( + onPressed: _onTestConnection, + icon: const Icon(Icons.wifi_tethering, size: 18), + label: text('Test', 13, TextType.Bold, color: colorPrimary), + ), + Padding( + padding: const EdgeInsets.only(right: 12, left: 8), + child: FilledButton.icon( + onPressed: _onSave, + icon: const Icon(Icons.check, size: 18), + label: text('Save', 14, TextType.Bold, color: colorWhite), + ), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 40), + children: [ + inputSection(context, 'Identity'), + inputField( + label: 'Server name', + hint: 'prod-1', + initial: _server.name, + onSaved: _onSaveName, + validator: requiredField, + ), + inputSection(context, 'Connection'), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: inputField( + label: 'Host / IP', + hint: '10.0.0.5 or example.com', + initial: _server.host, + onSaved: _onSaveHost, + validator: validHost, + ), + ), + const SizedBox(width: 12), + Expanded( + child: inputField( + label: 'Port', + initial: _server.port.toString(), + keyboardType: TextInputType.number, + onSaved: _onSavePort, + validator: validPort, + ), + ), + ], + ), + inputField( + label: 'Username', + hint: 'root / ubuntu', + initial: _server.username, + onSaved: _onSaveUsername, + validator: requiredField, + ), + inputField( + label: 'Password', + initial: _server.password, + obscure: !_showPassword, + onSaved: _onSavePassword, + suffix: IconButton( + icon: Icon( + _showPassword ? Icons.visibility_off : Icons.visibility, + ), + onPressed: _onTogglePassword, + ), + ), + inputHint( + context, + Icons.lock, + 'Stored encrypted in your OS keystore ' + '(Keychain / Keystore / DPAPI / libsecret).', + ), + inputSwitch( + context: context, + title: 'Use sudo for privileged commands', + subtitle: + 'Sends the password to sudo -S for systemctl / mkdir / rm.', + value: _server.useSudo, + onChanged: _onToggleSudo, + ), + ], + ), + ), + ), + ); + } + + void _onReady(ViewServerEditor model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async {} + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onSaveName(String v) => _server.name = v; + + void _onSaveHost(String v) => _server.host = v; + + void _onSavePort(String v) => _server.port = int.tryParse(v) ?? 22; + + void _onSaveUsername(String v) => _server.username = v; + + void _onSavePassword(String v) => _server.password = v; + + void _onTogglePassword() => setState(() => _showPassword = !_showPassword); + + void _onToggleSudo(bool v) => setState(() => _server.useSudo = v); + + /// Commits the form and hands the profile back to whoever opened the editor. + void _onSave() { + if (!_formKey.currentState!.validate()) return; + _formKey.currentState!.save(); + Navigator.of(context).pop(_server); + } + + /// Verifies the details as typed, without leaving the editor. + void _onTestConnection() { + if (!_formKey.currentState!.validate()) return; + _formKey.currentState!.save(); + _model?.testConnection(_server); + } + + // ── ConnectServerEditor ──────────────────────────────────────────────────── + + @override + void onConnectionVerified(ServerProfile server) { + // The success sheet raised by the ViewModel is the whole outcome; the form + // keeps its values so the user can save straight away. + } +} diff --git a/lib/Peach/see/server/editor/ViewServerEditor.dart b/lib/Peach/see/server/editor/ViewServerEditor.dart new file mode 100644 index 0000000..05e6d30 --- /dev/null +++ b/lib/Peach/see/server/editor/ViewServerEditor.dart @@ -0,0 +1,32 @@ +import '../../../about/internal/application/NotificationType.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../parent/ParentViewModel.dart'; +import 'ConnectServerEditor.dart'; + +class ViewServerEditor extends ParentViewModel { + ConnectServerEditor connection; + + ViewServerEditor(super.context, this.connection); + + /// Opens an SSH session with the details as entered, so the user finds out + /// now β€” not at first deploy β€” whether they work. + void testConnection(ServerProfile server) async { + showLoading("Connecting to ${server.host} …"); + try { + await getDataManager().connect(server); + closeLoading(); + connection.onConnectionVerified(server); + showApplicationNotification( + NotificationType.success, + "Connection verified", + "Peach signed in to ${server.username}@${server.host}:${server.port} " + "successfully.", + true, + true, + null, + ); + } catch (error) { + handleError(error, () => testConnection(server), () {}, "Retry"); + } + } +} diff --git a/lib/Peach/see/server/setup/ConnectServerSetup.dart b/lib/Peach/see/server/setup/ConnectServerSetup.dart new file mode 100644 index 0000000..673a926 --- /dev/null +++ b/lib/Peach/see/server/setup/ConnectServerSetup.dart @@ -0,0 +1,12 @@ +import '../../../comms/Comms.dart' show DeployResult; + +abstract class ConnectServerSetup { + /// The server answered: its package manager and what's already installed. + void onServerProbed(String packageManager, Map versions); + + /// The server couldn't be reached or probed. + void onProbeFailed(String message); + + /// The install finished; [versions] carries the re-probed tools. + void onInstallFinished(DeployResult result, Map versions); +} diff --git a/lib/Peach/see/server/setup/ServerSetup.dart b/lib/Peach/see/server/setup/ServerSetup.dart new file mode 100644 index 0000000..5314635 --- /dev/null +++ b/lib/Peach/see/server/setup/ServerSetup.dart @@ -0,0 +1,15 @@ +import 'package:flutter/cupertino.dart'; + +import '../../../about/internal/application/ServerProfile.dart'; +import 'ServerSetupState.dart'; + +/// Guided onboarding after registering a new server: connect β†’ show what the +/// environment already has β†’ ask what else to set up β†’ install it. +class ServerSetup extends StatefulWidget { + final ServerProfile server; + + const ServerSetup({super.key, required this.server}); + + @override + State createState() => ServerSetupState(); +} diff --git a/lib/Peach/see/server/setup/ServerSetupState.dart b/lib/Peach/see/server/setup/ServerSetupState.dart new file mode 100644 index 0000000..693a4f0 --- /dev/null +++ b/lib/Peach/see/server/setup/ServerSetupState.dart @@ -0,0 +1,652 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../../about/external/data/EnvCheck.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/StepTracker.dart'; +import '../../../about/internal/application/TextType.dart'; +import '../../../comms/Comms.dart' show DeployResult, LogLine; +import '../../../designs/LogConsole.dart'; +import '../../../designs/StepList.dart'; +import '../../../designs/text/Text.dart'; +import '../../../utils/Colors.dart'; +import 'ConnectServerSetup.dart'; +import 'ServerSetup.dart'; +import 'ViewServerSetup.dart'; + +class ServerSetupState extends State + implements ConnectServerSetup { + static const _labels = ['Connect', 'Choose', 'Install', 'Done']; + + ViewServerSetup? _model; + + StreamSubscription? _logSub; + StreamSubscription>? _stepSub; + + final _lines = []; + List _steps = []; + final Map _versions = {}; + final Set _selected = {}; + + int _step = 0; // 0 connect Β· 1 choose Β· 2 install Β· 3 done + bool _connecting = true; + bool _connected = false; + String? _connectError; + String _packageManager = 'unknown'; + bool _installing = false; + DeployResult? _result; + + ServerProfile get _server => widget.server; + + bool _installed(String key) => (_versions[key] ?? '').trim().isNotEmpty; + + List get _toInstall => + kEnvTools.where((t) => _selected.contains(t.key)).toList(); + + int get _presentCount => kEnvTools.where((t) => _installed(t.key)).length; + + @override + void dispose() { + _logSub?.cancel(); + _stepSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewServerSetup(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + 'Set up ${_server.name}', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + TextButton( + onPressed: _onSkip, + child: text('Skip', 13, TextType.Bold, color: colorPrimary), + ), + const SizedBox(width: 8), + ], + ), + body: Column( + children: [ + _progressBar(), + Expanded( + child: switch (_step) { + 0 => _connectStep(), + 1 => _chooseStep(viewModel), + 2 => _installStep(viewModel), + _ => _doneStep(), + }, + ), + ], + ), + ), + ); + } + + void _onReady(ViewServerSetup model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _logSub = _model?.logs.listen(_onLogLine); + _stepSub = _model?.steps.listen(_onSteps); + _model?.probeServer(_server); + } + + void _onLogLine(LogLine line) { + if (!mounted) return; + setState(() => _lines.add(line)); + } + + void _onSteps(List steps) { + if (!mounted) return; + setState(() => _steps = steps); + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onSkip() => Navigator.of(context).pop(); + + void _onFinish() => Navigator.of(context).pop(true); + + void _onRetryConnect() { + setState(() { + _connecting = true; + _connectError = null; + }); + _model?.probeServer(_server); + } + + void _onGoToChoose() => setState(() => _step = 1); + + void _onGoToConnect() => setState(() => _step = 0); + + void _onGoToInstall() => setState(() => _step = 2); + + void _onToggleTool(EnvTool tool, bool? selected) { + setState(() { + if (selected == true) { + _selected.add(tool.key); + } else { + _selected.remove(tool.key); + } + }); + } + + void _onInstall(ViewServerSetup model) { + setState(() { + _installing = true; + _lines.clear(); + _result = null; + }); + model.installEnvironment( + _server, + _toInstall, + model.buildInstallCommand(_toInstall, _packageManager), + ); + } + + void _onClearLogs() => setState(() => _lines.clear()); + + void _onCopyLogs() { + Clipboard.setData( + ClipboardData(text: _lines.map((l) => l.text).join('\n')), + ); + _notify('Logs copied.'); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectServerSetup ───────────────────────────────────────────────────── + + @override + void onServerProbed(String packageManager, Map versions) { + if (!mounted) return; + setState(() { + _packageManager = packageManager; + _versions + ..clear() + ..addAll(versions); + _connected = true; + _connecting = false; + _connectError = null; + }); + } + + @override + void onProbeFailed(String message) { + if (!mounted) return; + setState(() { + _connected = false; + _connecting = false; + _connectError = message; + }); + } + + @override + void onInstallFinished(DeployResult result, Map versions) { + if (!mounted) return; + setState(() { + _installing = false; + _result = result; + _versions.addAll(versions); + if (result.ok) _step = 3; + }); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _progressBar() { + final jp = context.jp; + return Container( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: BoxDecoration( + color: jp.surface, + border: Border(bottom: BorderSide(color: jp.border)), + ), + child: Row( + children: [ + for (var i = 0; i < _labels.length; i++) ...[ + _dot(i, _labels[i]), + if (i < _labels.length - 1) + Expanded( + child: Container( + height: 2, + margin: const EdgeInsets.symmetric(horizontal: 6), + color: i < _step ? PeachTheme.seed : jp.border, + ), + ), + ], + ], + ), + ); + } + + Widget _dot(int index, String label) { + final jp = context.jp; + final done = index < _step; + final active = index == _step; + final color = done || active ? PeachTheme.seed : jp.textFaint; + return Row( + children: [ + Container( + width: 26, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color.withValues(alpha: active ? 0.18 : (done ? 0.14 : 0.0)), + shape: BoxShape.circle, + border: Border.all(color: color.withValues(alpha: 0.6)), + ), + child: done + ? const Icon(Icons.check, size: 14, color: PeachTheme.seed) + : text('${index + 1}', 12, TextType.Bold, color: color), + ), + const SizedBox(width: 6), + text( + label, + 12.5, + active ? TextType.Bold : TextType.Regular, + color: active ? jp.text : jp.textFaint, + ), + ], + ); + } + + Widget _connectStep() { + final jp = context.jp; + return ListView( + padding: const EdgeInsets.all(20), + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed), + child: Column( + children: [ + if (_connecting) ...[ + const SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator(strokeWidth: 3), + ), + const SizedBox(height: 16), + text( + 'Connecting to ${_server.host} …', + 14, + TextType.Regular, + color: jp.textDim, + ), + ] else if (_connected) ...[ + const Icon( + Icons.check_circle, + color: PeachTheme.seed, + size: 44, + ), + const SizedBox(height: 12), + text('Connected', 18, TextType.Bold, color: jp.text), + const SizedBox(height: 6), + text( + 'Package manager: $_packageManager Β· ' + '$_presentCount/${kEnvTools.length} tools present', + 14, + TextType.Regular, + color: jp.textDim, + align: TextAlign.center, + ), + ] else ...[ + const Icon(Icons.error, color: PeachTheme.danger, size: 44), + const SizedBox(height: 12), + text( + 'Could not connect', + 14, + TextType.Bold, + color: PeachTheme.danger, + ), + const SizedBox(height: 6), + text( + _connectError ?? '', + 12, + TextType.Regular, + color: jp.textDim, + align: TextAlign.center, + ), + ], + ], + ), + ), + const SizedBox(height: 20), + Row( + children: [ + if (!_connected && !_connecting) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: _onRetryConnect, + icon: const Icon(Icons.refresh), + label: text('Retry', 14, TextType.Bold, color: jp.textDim), + ), + ), + const SizedBox(width: 10), + ], + Expanded( + child: FilledButton.icon( + onPressed: _connected ? _onGoToChoose : null, + icon: const Icon(Icons.arrow_forward), + label: text( + 'Choose environment', + 14, + TextType.Bold, + color: colorWhite, + ), + ), + ), + ], + ), + ], + ); + } + + Widget _chooseStep(ViewServerSetup model) { + final jp = context.jp; + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + text( + 'What should this server run?', + 18, + TextType.Bold, + color: jp.text, + ), + const SizedBox(height: 4), + text( + 'Tick anything missing you want Peach to install ' + '(via $_packageManager). Installed tools are already ready.', + 13, + TextType.Regular, + color: jp.textDim, + ), + const SizedBox(height: 16), + for (final tool in kEnvTools) _toolTile(tool), + ], + ), + ), + _bottomBar( + back: _onGoToConnect, + nextLabel: _selected.isEmpty + ? 'Nothing to install' + : 'Review & install', + onNext: _selected.isEmpty ? null : _onGoToInstall, + ), + ], + ); + } + + Widget _toolTile(EnvTool tool) { + final jp = context.jp; + final installed = _installed(tool.key); + final selected = _selected.contains(tool.key); + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: PeachTheme.panel(context, radius: 14), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: (installed ? PeachTheme.seed : jp.textFaint).withValues( + alpha: 0.14, + ), + borderRadius: BorderRadius.circular(11), + ), + child: Icon( + tool.icon, + color: installed ? PeachTheme.seed : jp.textDim, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text(tool.label, 14, TextType.Bold, color: jp.text), + text( + installed + ? (_versions[tool.key] ?? 'installed') + : 'not installed', + 11.5, + TextType.Regular, + color: installed ? jp.textDim : jp.textFaint, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (installed) + const Padding( + padding: EdgeInsets.only(right: 6), + child: Icon(Icons.check_circle, color: PeachTheme.seed), + ) + else + Checkbox(value: selected, onChanged: (v) => _onToggleTool(tool, v)), + ], + ), + ); + } + + Widget _installStep(ViewServerSetup model) { + final jp = context.jp; + final command = model.buildInstallCommand(_toInstall, _packageManager); + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + text('Review', 18, TextType.Bold, color: jp.text), + const SizedBox(height: 4), + text( + 'These will be installed${_server.useSudo ? ' via sudo' : ''}:', + 13, + TextType.Regular, + color: jp.textDim, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: _toInstall + .map( + (t) => Chip( + avatar: Icon(t.icon, size: 16), + label: Text(t.label), + ), + ) + .toList(), + ), + const SizedBox(height: 14), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: jp.consoleBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: ConsoleColors.frame), + ), + child: SelectableText( + command, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + color: ConsoleColors.body, + ), + ), + ), + if (_steps.isNotEmpty) ...[ + const SizedBox(height: 16), + StepList(steps: _steps), + ], + if (_result != null && !_result!.ok) ...[ + const SizedBox(height: 12), + _banner(_result!), + ], + if (_lines.isNotEmpty) ...[ + const SizedBox(height: 14), + LogConsole( + logs: _lines, + height: 240, + onClear: _onClearLogs, + onCopy: _onCopyLogs, + ), + ], + ], + ), + ), + _bottomBar( + back: _installing ? null : _onGoToChoose, + nextLabel: _installing ? 'Installing…' : 'Install now', + onNext: _installing ? null : () => _onInstall(model), + nextIcon: Icons.download, + ), + ], + ); + } + + Widget _doneStep() { + final jp = context.jp; + return ListView( + padding: const EdgeInsets.all(20), + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: PeachTheme.panel(context, glowColor: PeachTheme.seed), + child: Column( + children: [ + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: PeachTheme.brandGradient, + borderRadius: BorderRadius.circular(20), + boxShadow: PeachTheme.glow( + PeachTheme.seed, + opacity: 0.5, + blur: 28, + ), + ), + child: Icon(Icons.check, color: colorWhite, size: 38), + ), + const SizedBox(height: 16), + text( + '${_server.name} is ready', + 20, + TextType.Bold, + color: jp.text, + ), + const SizedBox(height: 6), + text( + '$_presentCount/${kEnvTools.length} tools installed. ' + 'Add a service to start deploying.', + 14, + TextType.Regular, + color: jp.textDim, + align: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _onFinish, + icon: const Icon(Icons.check), + label: text('Finish', 14, TextType.Bold, color: colorWhite), + ), + ], + ); + } + + Widget _bottomBar({ + VoidCallback? back, + required String nextLabel, + VoidCallback? onNext, + IconData nextIcon = Icons.arrow_forward, + }) { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 14), + child: Row( + children: [ + OutlinedButton( + onPressed: back, + child: text('Back', 13, TextType.Bold, color: context.jp.textDim), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton.icon( + onPressed: onNext, + icon: _installing + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorWhite, + ), + ) + : Icon(nextIcon), + label: text(nextLabel, 14, TextType.Bold, color: colorWhite), + ), + ), + ], + ), + ), + ); + } + + Widget _banner(DeployResult result) { + final color = result.ok ? PeachTheme.seed : PeachTheme.danger; + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(result.ok ? Icons.check_circle : Icons.error, color: color), + const SizedBox(width: 10), + Expanded( + child: text(result.message, 14, TextType.Regular, color: color), + ), + ], + ), + ); + } +} diff --git a/lib/Peach/see/server/setup/ViewServerSetup.dart b/lib/Peach/see/server/setup/ViewServerSetup.dart new file mode 100644 index 0000000..68d964b --- /dev/null +++ b/lib/Peach/see/server/setup/ViewServerSetup.dart @@ -0,0 +1,76 @@ +import '../../../about/external/data/EnvCheck.dart'; +import '../../../about/internal/application/ServerProfile.dart'; +import '../../../about/internal/application/StepTracker.dart'; +import '../../../comms/Comms.dart' show LogLine; +import '../../parent/ParentViewModel.dart'; +import 'ConnectServerSetup.dart'; + +class ViewServerSetup extends ParentViewModel { + ConnectServerSetup connection; + + ViewServerSetup(super.context, this.connection); + + Stream get logs => getDataManager().logs; + + Stream> get steps => getDataManager().stepStream; + + /// Step 1: prove the credentials work and read what's already there. The + /// failure is reported inline rather than as an overlay β€” on this screen a + /// failed connection is a step of the wizard, not an interruption. + void probeServer(ServerProfile server) async { + try { + final packageManager = await getDataManager().detectPackageManager( + server, + ); + final versions = await getDataManager().probeEnvironment( + server, + kEnvTools.map((t) => '${t.key} ${t.detect}').toList(), + ); + connection.onServerProbed(packageManager, versions); + } catch (error) { + connection.onProbeFailed(error.toString()); + } + } + + /// Step 3: install everything the user ticked in one package-manager call, + /// enable the units that need it, then re-probe those tools. + void installEnvironment( + ServerProfile server, + List tools, + String command, + ) async { + try { + final result = await getDataManager().installEnvironment( + server: server, + command: command, + units: tools.where((t) => t.unit != null).map((t) => t.unit!).toList(), + ); + + var versions = {}; + try { + versions = await getDataManager().probeEnvironment( + server, + tools.map((t) => '${t.key} ${t.detect}').toList(), + ); + } catch (_) { + // The install result is what matters; a failed re-probe just leaves + // the tiles showing their previous state. + } + + connection.onInstallFinished(result, versions); + } catch (error) { + handleError( + error, + () => installEnvironment(server, tools, command), + () {}, + "Retry", + ); + } + } + + /// The single command that installs every selected tool. + String buildInstallCommand(List tools, String packageManager) { + final packages = tools.map((t) => t.packagesFor(packageManager)).join(' '); + return installCommandFor(packageManager, packages); + } +} diff --git a/lib/Peach/see/service/ConnectServiceEditor.dart b/lib/Peach/see/service/ConnectServiceEditor.dart new file mode 100644 index 0000000..693d85c --- /dev/null +++ b/lib/Peach/see/service/ConnectServiceEditor.dart @@ -0,0 +1,4 @@ +abstract class ConnectServiceEditor { + /// A local project folder was chosen for the build step. + void onProjectDirectoryPicked(String path); +} diff --git a/lib/Peach/see/service/ServiceEditor.dart b/lib/Peach/see/service/ServiceEditor.dart new file mode 100644 index 0000000..e863c94 --- /dev/null +++ b/lib/Peach/see/service/ServiceEditor.dart @@ -0,0 +1,16 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/ServiceConfig.dart'; +import 'ServiceEditorState.dart'; + +/// Edits a single service belonging to a server (jar service, web app or a +/// generic binary). +class ServiceEditor extends StatefulWidget { + /// null = create new. + final ServiceConfig? service; + + const ServiceEditor({super.key, this.service}); + + @override + State createState() => ServiceEditorState(); +} diff --git a/lib/Peach/see/service/ServiceEditorState.dart b/lib/Peach/see/service/ServiceEditorState.dart new file mode 100644 index 0000000..9d62ddd --- /dev/null +++ b/lib/Peach/see/service/ServiceEditorState.dart @@ -0,0 +1,454 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/internal/application/ServiceConfig.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../designs/input/InputFields.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../../utils/Validators.dart'; +import 'ConnectServiceEditor.dart'; +import 'ServiceEditor.dart'; +import 'ViewServiceEditor.dart'; + +class ServiceEditorState extends State + implements ConnectServiceEditor { + ViewServiceEditor? _model; + + final _formKey = GlobalKey(); + late final ServiceConfig _svc; + late final TextEditingController _projectPath; + + @override + void initState() { + super.initState(); + _svc = widget.service?.copy() ?? ServiceConfig(); + _projectPath = TextEditingController(text: _svc.localProjectPath); + } + + @override + void dispose() { + _projectPath.dispose(); + super.dispose(); + } + + bool get _isNew => widget.service == null; + bool get _isWeb => _svc.kind == ServiceKind.webApp; + bool get _isGeneric => _svc.kind == ServiceKind.generic; + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewServiceEditor(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + _isNew ? 'New service' : 'Edit service', + 20, + TextType.Bold, + color: context.jp.text, + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12), + child: FilledButton.icon( + onPressed: _onSave, + icon: const Icon(Icons.check, size: 18), + label: text('Save', 14, TextType.Bold, color: colorWhite), + ), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 40), + children: [ + inputField( + label: 'Service label', + hint: _isWeb ? 'marketing-site' : 'orders-api', + initial: _svc.name, + onSaved: _onSaveName, + validator: requiredField, + ), + inputSection(context, 'Deployment type'), + _kindSelector(), + if (_isWeb) + ..._webFields() + else if (_isGeneric) + ..._genericFields() + else + ..._jarFields(), + inputSection(context, 'Build from source (optional)'), + _sectionHint(_buildBlurb(viewModel)), + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: TextFormField( + controller: _projectPath, + decoration: InputDecoration( + labelText: 'Local project path', + hintText: r'C:\code\app or /home/me/app', + suffixIcon: IconButton( + tooltip: 'Choose folder', + icon: const Icon(Icons.folder_open), + onPressed: _onPickProjectDir, + ), + ), + ), + ), + inputField( + label: 'Build command (optional)', + hint: _buildCommandHint(), + initial: _svc.buildCommand, + onSaved: _onSaveBuildCommand, + ), + if (_isWeb) + inputField( + label: 'Build output dir (optional)', + hint: 'blank β†’ auto (dist/ Β· build/ Β· build/web)', + initial: _svc.outputSubdir, + onSaved: _onSaveOutputSubdir, + ) + else + inputField( + label: _isGeneric + ? 'Built artifact path (optional)' + : 'Built jar pattern (optional)', + hint: _isGeneric + ? 'e.g. target/release/myapp (explicit path)' + : 'blank β†’ target/*.jar or build/libs/*.jar', + initial: _svc.jarPattern, + onSaved: _onSaveJarPattern, + ), + ..._healthSection(), + ], + ), + ), + ), + ); + } + + void _onReady(ViewServiceEditor model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async {} + + String _buildBlurb(ViewServiceEditor model) { + if (!model.canBuildLocally) return 'Local builds run on desktop only.'; + return _isWeb + ? 'Point at the local project and Peach builds the site, then ' + 'publishes it to nginx.' + : 'Point at the local project and Peach builds the jar ' + '(Maven/Gradle) before deploying.'; + } + + String _buildCommandHint() { + if (_isWeb) { + final fallback = _svc.framework.defaultBuild; + return 'blank β†’ ${fallback.isEmpty ? "none" : fallback}'; + } + return _isGeneric + ? 'e.g. make build / cargo build --release' + : 'blank β†’ mvn -DskipTests package / gradlew build'; + } + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onSaveName(String v) => _svc.name = v; + + void _onSaveBuildCommand(String v) => _svc.buildCommand = v; + + void _onSaveOutputSubdir(String v) => _svc.outputSubdir = v; + + void _onSaveJarPattern(String v) => _svc.jarPattern = v; + + void _onSaveRemoteDir(String v) => _svc.remoteDir = v; + + void _onSaveRemoteJarName(String v) => _svc.remoteJarName = v; + + void _onSaveServiceName(String v) => _svc.serviceName = v; + + void _onSaveLogCommand(String v) => _svc.logCommand = v; + + void _onSaveNginxService(String v) => + _svc.nginxService = v.trim().isEmpty ? 'nginx' : v; + + void _onSaveHealthTarget(String v) => _svc.healthTarget = v; + + void _onPickProjectDir() => _model?.pickProjectDirectory(); + + void _onSave() { + if (!_formKey.currentState!.validate()) return; + _formKey.currentState!.save(); + _svc.localProjectPath = _projectPath.text.trim(); + Navigator.of(context).pop(_svc); + } + + /// Switching kind moves the remote directory to that kind's default, but + /// only when the user hasn't typed their own. + void _onSelectKind(ServiceKind kind) { + setState(() { + _svc.kind = kind; + final dir = _svc.remoteDir.trim(); + if (kind == ServiceKind.webApp && (dir.isEmpty || dir == '/opt/app')) { + _svc.remoteDir = '/var/www/html'; + } else if (kind != ServiceKind.webApp && dir == '/var/www/html') { + _svc.remoteDir = '/opt/app'; + } + }); + } + + void _onSelectFramework(WebFramework f) => setState(() => _svc.framework = f); + + void _onSelectHealthType(HealthCheckType h) => + setState(() => _svc.healthType = h); + + // ── ConnectServiceEditor ─────────────────────────────────────────────────── + + @override + void onProjectDirectoryPicked(String path) { + if (!mounted) return; + setState(() => _projectPath.text = path); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + List _healthSection() => [ + inputSection(context, 'Health check'), + _sectionHint( + 'How the Health dashboard probes this service. "systemd (auto)" ' + 'checks the unit is active.', + ), + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: HealthCheckType.values + .map( + (h) => _choice( + h.label, + _svc.healthType == h, + () => _onSelectHealthType(h), + ), + ) + .toList(), + ), + ), + if (_svc.healthType == HealthCheckType.http || + _svc.healthType == HealthCheckType.tcp || + _svc.healthType == HealthCheckType.command) + inputField( + label: switch (_svc.healthType) { + HealthCheckType.http => 'Health URL', + HealthCheckType.tcp => 'Port', + _ => 'Health command', + }, + hint: switch (_svc.healthType) { + HealthCheckType.http => 'http://localhost:8080/health', + HealthCheckType.tcp => '8080', + _ => 'systemctl is-active myapp', + }, + initial: _svc.healthTarget, + onSaved: _onSaveHealthTarget, + ), + ]; + + Widget _kindSelector() { + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _kindCard( + ServiceKind.jarService, + Icons.settings_suggest, + 'Jar service', + 'systemd + jar', + ), + _kindCard( + ServiceKind.webApp, + Icons.language, + 'Web app', + 'static β†’ nginx', + ), + _kindCard(ServiceKind.generic, Icons.terminal, 'Generic', 'any binary'), + ], + ); + } + + Widget _kindCard(ServiceKind kind, IconData icon, String title, String sub) { + final jp = context.jp; + final active = _svc.kind == kind; + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _onSelectKind(kind), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + width: 150, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: active ? PeachTheme.seed.withValues(alpha: 0.12) : jp.surface2, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: active ? PeachTheme.seed : jp.border, + width: active ? 1.6 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: active ? PeachTheme.seed : jp.textDim), + const SizedBox(height: 10), + text(title, 14, TextType.Bold, color: jp.text), + const SizedBox(height: 2), + text(sub, 11.5, TextType.Regular, color: jp.textFaint), + ], + ), + ), + ); + } + + Widget _frameworkSelector() { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: text( + 'Framework', + 13, + TextType.Regular, + color: context.jp.textDim, + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: WebFramework.values + .map( + (f) => _choice( + f.label, + _svc.framework == f, + () => _onSelectFramework(f), + ), + ) + .toList(), + ), + ], + ), + ); + } + + Widget _choice(String label, bool active, VoidCallback onSelected) { + final jp = context.jp; + return ChoiceChip( + selected: active, + label: Text(label), + onSelected: (_) => onSelected(), + showCheckmark: false, + selectedColor: PeachTheme.seed.withValues(alpha: 0.20), + backgroundColor: jp.surface2, + side: BorderSide(color: active ? PeachTheme.seed : jp.border), + labelStyle: TextStyle( + color: active ? PeachTheme.seed : jp.textDim, + fontWeight: FontWeight.w600, + ), + ); + } + + List _jarFields() => [ + inputSection(context, 'Jar service'), + inputField( + label: 'Remote directory', + hint: '/opt/orders', + initial: _svc.remoteDir, + onSaved: _onSaveRemoteDir, + validator: validRemotePath, + ), + inputField( + label: 'Jar filename on server (optional)', + hint: 'orders.jar β€” blank keeps the dropped name', + initial: _svc.remoteJarName, + onSaved: _onSaveRemoteJarName, + ), + inputField( + label: 'systemd unit name (optional)', + hint: 'orders β†’ systemctl restart orders', + initial: _svc.serviceName, + onSaved: _onSaveServiceName, + ), + inputField( + label: 'Log command (optional)', + hint: 'blank β†’ journalctl -u -n 200', + initial: _svc.logCommand, + onSaved: _onSaveLogCommand, + ), + ]; + + List _webFields() => [ + const SizedBox(height: 6), + _frameworkSelector(), + inputField( + label: 'nginx web root', + hint: '/var/www/marketing', + initial: _svc.remoteDir, + onSaved: _onSaveRemoteDir, + validator: validRemotePath, + ), + inputField( + label: 'nginx unit name', + hint: 'nginx', + initial: _svc.nginxService, + onSaved: _onSaveNginxService, + ), + inputField( + label: 'Log command (optional)', + hint: 'blank β†’ journalctl -u nginx -n 200', + initial: _svc.logCommand, + onSaved: _onSaveLogCommand, + ), + ]; + + List _genericFields() => [ + inputSection(context, 'Service (any binary / script)'), + inputField( + label: 'Remote directory', + hint: '/opt/myapp', + initial: _svc.remoteDir, + onSaved: _onSaveRemoteDir, + validator: validRemotePath, + ), + inputField( + label: 'Target filename on server', + hint: 'myapp (the uploaded file is chmod +x)', + initial: _svc.remoteJarName, + onSaved: _onSaveRemoteJarName, + ), + inputField( + label: 'systemd unit name (optional)', + hint: 'myapp β†’ systemctl restart myapp', + initial: _svc.serviceName, + onSaved: _onSaveServiceName, + ), + inputField( + label: 'Log command (optional)', + hint: 'blank β†’ journalctl -u -n 200', + initial: _svc.logCommand, + onSaved: _onSaveLogCommand, + ), + ]; + + Widget _sectionHint(String message) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: text(message, 12.5, TextType.Regular, color: context.jp.textFaint), + ); +} diff --git a/lib/Peach/see/service/ViewServiceEditor.dart b/lib/Peach/see/service/ViewServiceEditor.dart new file mode 100644 index 0000000..f10dbd6 --- /dev/null +++ b/lib/Peach/see/service/ViewServiceEditor.dart @@ -0,0 +1,26 @@ +import 'package:file_picker/file_picker.dart'; + +import '../parent/ParentViewModel.dart'; +import 'ConnectServiceEditor.dart'; + +class ViewServiceEditor extends ParentViewModel { + ConnectServiceEditor connection; + + ViewServiceEditor(super.context, this.connection); + + /// True on desktop, where a local build can actually run. + bool get canBuildLocally => getDataManager().buildSupported; + + /// Opens the OS folder picker for the service's source project. + void pickProjectDirectory() async { + try { + final directory = await FilePicker.getDirectoryPath( + dialogTitle: 'Select the service project folder', + ); + if (directory == null) return; + connection.onProjectDirectoryPicked(directory); + } catch (error) { + handleError(error, pickProjectDirectory, () {}, "Retry"); + } + } +} diff --git a/lib/Peach/see/splash/ConnectSplash.dart b/lib/Peach/see/splash/ConnectSplash.dart new file mode 100644 index 0000000..bd2cbd4 --- /dev/null +++ b/lib/Peach/see/splash/ConnectSplash.dart @@ -0,0 +1,13 @@ +import '../../about/internal/application/DeploymentRecord.dart'; +import '../../about/internal/application/ServerProfile.dart'; + +/// What [ViewSplash] hands back to the screen once the app's stored state has +/// been read. The ViewModel never touches widgets β€” it calls these. +abstract class ConnectSplash { + /// Everything needed to open Home, loaded and ready. + void onBootstrapped( + List servers, + List history, + String themeMode, + ); +} diff --git a/lib/Peach/see/splash/Splash.dart b/lib/Peach/see/splash/Splash.dart new file mode 100644 index 0000000..42f8b11 --- /dev/null +++ b/lib/Peach/see/splash/Splash.dart @@ -0,0 +1,10 @@ +import 'package:flutter/cupertino.dart'; + +import 'SplashState.dart'; + +class Splash extends StatefulWidget { + const Splash({super.key}); + + @override + State createState() => SplashState(); +} diff --git a/lib/Peach/see/splash/SplashState.dart b/lib/Peach/see/splash/SplashState.dart new file mode 100644 index 0000000..f7ee05e --- /dev/null +++ b/lib/Peach/see/splash/SplashState.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/internal/application/DeploymentRecord.dart'; +import '../../about/internal/application/NavigatorType.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../configs/Navigator.dart'; +import '../../configs/ThemeController.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import '../home/Home.dart'; +import 'ConnectSplash.dart'; +import 'Splash.dart'; +import 'ViewSplash.dart'; + +class SplashState extends State + with SingleTickerProviderStateMixin + implements ConnectSplash { + ViewSplash? _model; + + late final AnimationController _pulse; + + @override + void initState() { + super.initState(); + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + } + + @override + void dispose() { + _pulse.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewSplash(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _brandMark(), + const SizedBox(height: 24), + text( + 'Peach', + 36, + TextType.Bold, + context: context, + letterSpacing: -0.8, + ), + const SizedBox(height: 6), + text( + 'DEPLOYMENT COCKPIT', + 11.5, + TextType.Bold, + color: context.jp.textFaint, + letterSpacing: 4, + ), + const SizedBox(height: 40), + SizedBox( + width: 150, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: LinearProgressIndicator( + minHeight: 3, + backgroundColor: context.jp.surface2, + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _onReady(ViewSplash model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _model?.bootstrap(); + } + + // ── ConnectSplash ────────────────────────────────────────────────────────── + + @override + void onBootstrapped( + List servers, + List history, + String themeMode, + ) { + if (!mounted) return; + // Apply the stored theme before the dashboard paints, so it never flashes + // the default and then switches. + ThemeController.apply(themeMode); + PeachNavigation().navigateToPage( + NavigatorType.makeNewMain, + Home(servers: servers, history: history), + context, + ); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + /// Layered logo: a soft pulsing halo behind a beveled brand tile. + Widget _brandMark() { + return SizedBox( + width: 132, + height: 132, + child: Stack( + alignment: Alignment.center, + children: [ + ScaleTransition( + scale: Tween( + begin: 0.9, + end: 1.12, + ).animate(CurvedAnimation(parent: _pulse, curve: Curves.easeInOut)), + child: Container( + width: 132, + height: 132, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + PeachTheme.seed.withValues(alpha: 0.28), + PeachTheme.seed.withValues(alpha: 0), + ], + ), + ), + ), + ), + Container( + width: 90, + height: 90, + decoration: BoxDecoration( + gradient: PeachTheme.brandGradient, + borderRadius: BorderRadius.circular(26), + border: Border.all( + color: colorWhite.withValues(alpha: 0.18), + width: 1, + ), + boxShadow: PeachTheme.glow( + PeachTheme.seed, + opacity: 0.5, + blur: 44, + spread: 0, + ), + ), + child: Icon(Icons.rocket_launch, color: colorWhite, size: 44), + ), + ], + ), + ); + } +} diff --git a/lib/Peach/see/splash/ViewSplash.dart b/lib/Peach/see/splash/ViewSplash.dart new file mode 100644 index 0000000..d233c1e --- /dev/null +++ b/lib/Peach/see/splash/ViewSplash.dart @@ -0,0 +1,28 @@ +import '../parent/ParentViewModel.dart'; +import 'ConnectSplash.dart'; + +class ViewSplash extends ParentViewModel { + ConnectSplash connection; + + ViewSplash(super.context, this.connection); + + /// Reads the stored servers, deployment history and theme preference, then + /// hands them to the screen. The splash is deliberately held for a minimum + /// duration so a fast read doesn't make it flash. + void bootstrap() async { + final started = DateTime.now(); + try { + final servers = await getDataManager().getServers(); + final history = await getDataManager().getDeployments(); + final themeMode = await getDataManager().getThemeMode(); + + const minShow = Duration(milliseconds: 1300); + final elapsed = DateTime.now().difference(started); + if (elapsed < minShow) await Future.delayed(minShow - elapsed); + + connection.onBootstrapped(servers, history, themeMode); + } catch (error) { + handleError(error, bootstrap, () {}, "Retry"); + } + } +} diff --git a/lib/Peach/see/systemd/ConnectSystemd.dart b/lib/Peach/see/systemd/ConnectSystemd.dart new file mode 100644 index 0000000..77f851f --- /dev/null +++ b/lib/Peach/see/systemd/ConnectSystemd.dart @@ -0,0 +1,6 @@ +import '../../comms/Comms.dart' show DeployResult; + +abstract class ConnectSystemd { + /// The unit was written and registered (or failed to be). + void onUnitInstalled(DeployResult result); +} diff --git a/lib/Peach/see/systemd/Systemd.dart b/lib/Peach/see/systemd/Systemd.dart new file mode 100644 index 0000000..6f81c73 --- /dev/null +++ b/lib/Peach/see/systemd/Systemd.dart @@ -0,0 +1,20 @@ +import 'package:flutter/cupertino.dart'; + +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/ServiceConfig.dart'; +import 'SystemdState.dart'; + +/// Step-by-step builder that generates a systemd `.service` unit, previews it, +/// then registers it (daemon-reload β†’ enable β†’ start β†’ status). +class Systemd extends StatefulWidget { + final ServerProfile server; + + /// Prefills the form and, once the unit registers, has its `serviceName` + /// updated so deploys and log views use the new unit. + final ServiceConfig? service; + + const Systemd({super.key, required this.server, this.service}); + + @override + State createState() => SystemdState(); +} diff --git a/lib/Peach/see/systemd/SystemdState.dart b/lib/Peach/see/systemd/SystemdState.dart new file mode 100644 index 0000000..c4786af --- /dev/null +++ b/lib/Peach/see/systemd/SystemdState.dart @@ -0,0 +1,583 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stacked/stacked.dart'; + +import '../../about/external/initial/SystemdSpec.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/StepTracker.dart'; +import '../../about/internal/application/TextType.dart'; +import '../../comms/Comms.dart' show DeployResult, LogLine; +import '../../designs/LogConsole.dart'; +import '../../designs/StepList.dart'; +import '../../designs/text/Text.dart'; +import '../../utils/Colors.dart'; +import 'ConnectSystemd.dart'; +import 'Systemd.dart'; +import 'ViewSystemd.dart'; + +class SystemdState extends State implements ConnectSystemd { + static const _dependencyOptions = { + 'mariadb.service': 'MariaDB', + 'mysql.service': 'MySQL', + 'postgresql.service': 'PostgreSQL', + 'docker.service': 'Docker', + 'redis.service': 'Redis', + 'nginx.service': 'nginx', + }; + + ViewSystemd? _model; + + StreamSubscription? _logSub; + StreamSubscription>? _stepSub; + + final _lines = []; + List _steps = []; + bool _applying = false; + DeployResult? _result; + + late final TextEditingController _name; + late final TextEditingController _description; + late final TextEditingController _user; + late final TextEditingController _workDir; + late final TextEditingController _exec; + late final TextEditingController _restartSec; + late final TextEditingController _environment; + + SystemdType _type = SystemdType.simple; + RestartPolicy _restart = RestartPolicy.onFailure; + final Set _after = {}; + bool _javaExit = false; + bool _enableOnBoot = true; + bool _startNow = true; + + ServerProfile get _server => widget.server; + + @override + void initState() { + super.initState(); + final service = widget.service; + final isJar = service != null && !service.isWeb; + final jarPath = service == null + ? '/opt/app/app.jar' + : service.remotePath( + service.remoteJarName.trim().isEmpty + ? 'app.jar' + : service.remoteJarName, + ); + + _name = TextEditingController( + text: service?.serviceName.trim().isNotEmpty == true + ? service!.serviceName.trim() + : (service?.name ?? 'myapp'), + ); + _description = TextEditingController( + text: 'Peach Β· ${service?.name ?? 'service'}', + ); + _user = TextEditingController(text: _server.username); + _workDir = TextEditingController(text: service?.remoteRoot ?? '/opt/app'); + _exec = TextEditingController( + text: isJar ? '/usr/bin/java -jar $jarPath' : '', + ); + _restartSec = TextEditingController(text: '5'); + _environment = TextEditingController(); + _javaExit = isJar; + } + + @override + void dispose() { + _logSub?.cancel(); + _stepSub?.cancel(); + for (final c in [ + _name, + _description, + _user, + _workDir, + _exec, + _restartSec, + _environment, + ]) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => ViewSystemd(context, this), + onViewModelReady: _onReady, + builder: (context, viewModel, child) => Scaffold( + appBar: AppBar( + title: text( + 'Register systemd service', + 20, + TextType.Bold, + color: context.jp.text, + ), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 40), + children: [ + _sectionTitle('1 Β· Unit', Icons.badge_outlined), + _field('Unit name', _name, hint: 'orders β†’ orders.service'), + _field('Description', _description), + Row( + children: [ + Expanded(child: _typeDropdown()), + const SizedBox(width: 12), + Expanded(child: _restartDropdown()), + ], + ), + const SizedBox(height: 12), + _sectionTitle('2 Β· Process', Icons.terminal), + _field('User', _user), + _field('Working directory', _workDir, hint: '/opt/app'), + _field( + 'ExecStart', + _exec, + hint: '/usr/bin/java -jar /opt/app/app.jar', + mono: true, + ), + Row( + children: [ + SizedBox( + width: 130, + child: _field( + 'RestartSec', + _restartSec, + keyboard: TextInputType.number, + ), + ), + const SizedBox(width: 12), + Expanded( + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + value: _javaExit, + onChanged: _onJavaExitChanged, + title: text( + 'JVM SuccessExitStatus=143', + 14, + TextType.Regular, + color: context.jp.text, + ), + ), + ), + ], + ), + _field( + 'Environment (KEY=VALUE per line)', + _environment, + hint: 'SPRING_PROFILES_ACTIVE=prod\nJAVA_OPTS=-Xmx512m', + mono: true, + lines: 3, + ), + const SizedBox(height: 12), + _sectionTitle('3 Β· Dependencies', Icons.account_tree), + text( + 'Starts after network.target', + 12, + TextType.Regular, + color: context.jp.textFaint, + ), + const SizedBox(height: 8), + _dependencyChips(), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _enableOnBoot, + onChanged: _onEnableChanged, + title: text( + 'Enable on boot', + 14, + TextType.Regular, + color: context.jp.text, + ), + subtitle: text( + 'systemctl enable', + 12, + TextType.Light, + color: context.jp.textDim, + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _startNow, + onChanged: _onStartNowChanged, + title: text( + 'Start now', + 14, + TextType.Regular, + color: context.jp.text, + ), + subtitle: text( + 'systemctl restart after install', + 12, + TextType.Light, + color: context.jp.textDim, + ), + ), + const SizedBox(height: 16), + _sectionTitle('4 Β· Preview & confirm', Icons.rule), + _previewCard(_spec()), + const SizedBox(height: 10), + _plannedCommands(_spec()), + const SizedBox(height: 14), + FilledButton.icon( + onPressed: _applying ? null : _onApply, + icon: _applying + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorWhite, + ), + ) + : const Icon(Icons.check_circle), + label: text( + _applying ? 'Registering…' : 'Install & register unit', + 14, + TextType.Bold, + color: colorWhite, + ), + ), + if (_steps.isNotEmpty) ...[ + const SizedBox(height: 16), + StepList(steps: _steps), + ], + if (_result != null) ...[ + const SizedBox(height: 14), + _resultBanner(_result!), + ], + if (_lines.isNotEmpty) ...[ + const SizedBox(height: 14), + LogConsole( + logs: _lines, + height: 260, + onClear: _onClearLogs, + onCopy: _onCopyLogs, + ), + ], + ], + ), + ), + ); + } + + void _onReady(ViewSystemd model) { + _model = model; + // onViewModelReady fires mid-build; a synchronous setState from the + // first load would dirty this State while it is still being built. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _initiate(); + }); + } + + Future _initiate() async { + _logSub = _model?.logs.listen(_onLogLine); + _stepSub = _model?.steps.listen(_onSteps); + } + + void _onLogLine(LogLine line) { + if (!mounted) return; + setState(() => _lines.add(line)); + } + + void _onSteps(List steps) { + if (!mounted) return; + setState(() => _steps = steps); + } + + SystemdSpec _spec() => SystemdSpec( + unitName: _name.text, + description: _description.text, + type: _type, + user: _user.text, + workingDirectory: _workDir.text, + execStart: _exec.text, + restart: _restart, + restartSec: int.tryParse(_restartSec.text) ?? 5, + environmentLines: _environment.text, + afterUnits: ['network.target', ..._after], + javaSuccessExit: _javaExit, + enableOnBoot: _enableOnBoot, + startNow: _startNow, + ); + + // ── named handlers ───────────────────────────────────────────────────────── + + void _onApply() { + final spec = _spec(); + if (spec.safeName.isEmpty) { + _notify('Enter a unit name.'); + return; + } + if (spec.execStart.trim().isEmpty) { + _notify('Enter an ExecStart command.'); + return; + } + setState(() { + _applying = true; + _lines.clear(); + _result = null; + }); + _model?.installUnit(_server, spec, widget.service); + } + + void _onTypeChanged(SystemdType? v) => + setState(() => _type = v ?? SystemdType.simple); + + void _onRestartChanged(RestartPolicy? v) => + setState(() => _restart = v ?? RestartPolicy.onFailure); + + void _onJavaExitChanged(bool v) => setState(() => _javaExit = v); + + void _onEnableChanged(bool v) => setState(() => _enableOnBoot = v); + + void _onStartNowChanged(bool v) => setState(() => _startNow = v); + + void _onDependencyChanged(String unit, bool selected) { + setState(() { + if (selected) { + _after.add(unit); + } else { + _after.remove(unit); + } + }); + } + + /// The preview re-renders from the live field values, so every keystroke + /// has to repaint. + void _onFieldChanged(String _) => setState(() {}); + + void _onCopyUnit(SystemdSpec spec) { + Clipboard.setData(ClipboardData(text: spec.render())); + _notify('Unit file copied.'); + } + + void _onClearLogs() => setState(() => _lines.clear()); + + void _onCopyLogs() { + Clipboard.setData( + ClipboardData(text: _lines.map((l) => l.text).join('\n')), + ); + _notify('Logs copied.'); + } + + void _notify(String message) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + // ── ConnectSystemd ───────────────────────────────────────────────────────── + + @override + void onUnitInstalled(DeployResult result) { + if (!mounted) return; + setState(() { + _applying = false; + _result = result; + }); + } + + // ── screen-only widgets ──────────────────────────────────────────────────── + + Widget _sectionTitle(String title, IconData icon) => Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10), + child: Row( + children: [ + Icon(icon, size: 16, color: PeachTheme.seed), + const SizedBox(width: 8), + text( + title.toUpperCase(), + 12.5, + TextType.Bold, + color: context.jp.text, + letterSpacing: 0.8, + ), + ], + ), + ); + + Widget _field( + String label, + TextEditingController controller, { + String? hint, + bool mono = false, + int lines = 1, + TextInputType? keyboard, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: TextField( + controller: controller, + maxLines: lines, + keyboardType: keyboard, + onChanged: _onFieldChanged, + style: mono + ? const TextStyle(fontFamily: 'monospace', fontSize: 13) + : null, + decoration: InputDecoration(labelText: label, hintText: hint), + ), + ); + } + + Widget _typeDropdown() => DropdownButtonFormField( + initialValue: _type, + decoration: const InputDecoration(labelText: 'Type'), + items: SystemdType.values + .map((t) => DropdownMenuItem(value: t, child: Text(t.name))) + .toList(), + onChanged: _onTypeChanged, + ); + + Widget _restartDropdown() => DropdownButtonFormField( + initialValue: _restart, + decoration: const InputDecoration(labelText: 'Restart'), + items: RestartPolicy.values + .map((r) => DropdownMenuItem(value: r, child: Text(r.label))) + .toList(), + onChanged: _onRestartChanged, + ); + + Widget _dependencyChips() { + final jp = context.jp; + return Wrap( + spacing: 8, + runSpacing: 8, + children: _dependencyOptions.entries.map((entry) { + final on = _after.contains(entry.key); + return FilterChip( + label: Text(entry.value), + selected: on, + onSelected: (s) => _onDependencyChanged(entry.key, s), + showCheckmark: false, + selectedColor: PeachTheme.seed.withValues(alpha: 0.20), + backgroundColor: jp.surface2, + side: BorderSide(color: on ? PeachTheme.seed : jp.border), + labelStyle: TextStyle( + color: on ? PeachTheme.seed : jp.textDim, + fontWeight: FontWeight.w600, + ), + ); + }).toList(), + ); + } + + Widget _previewCard(SystemdSpec spec) { + final jp = context.jp; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: jp.consoleBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: ConsoleColors.frame), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.description, size: 15, color: PeachTheme.seed), + const SizedBox(width: 8), + Expanded( + child: Text( + '/etc/systemd/system/${spec.safeName}.service', + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: ConsoleColors.header, + ), + ), + ), + IconButton( + tooltip: 'Copy', + iconSize: 15, + color: ConsoleColors.header, + onPressed: () => _onCopyUnit(spec), + icon: const Icon(Icons.copy_all), + ), + ], + ), + const Divider(color: ConsoleColors.frame), + SelectableText( + spec.render(), + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12.5, + height: 1.4, + color: ConsoleColors.body, + ), + ), + ], + ), + ); + } + + Widget _plannedCommands(SystemdSpec spec) { + final jp = context.jp; + final commands = spec.plannedCommands(sudo: _server.useSudo); + return Container( + padding: const EdgeInsets.all(14), + decoration: PeachTheme.panel(context, radius: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + text( + 'THESE COMMANDS WILL RUN', + 11, + TextType.Bold, + color: jp.textDim, + letterSpacing: 0.8, + ), + const SizedBox(height: 8), + for (final command in commands) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon(Icons.chevron_right, size: 15, color: jp.textFaint), + const SizedBox(width: 4), + Expanded( + child: text( + command, + 12.5, + TextType.Regular, + color: jp.textDim, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _resultBanner(DeployResult result) { + final color = result.ok ? PeachTheme.seed : PeachTheme.danger; + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.5)), + ), + child: Row( + children: [ + Icon(result.ok ? Icons.check_circle : Icons.error, color: color), + const SizedBox(width: 10), + Expanded( + child: text(result.message, 14, TextType.Regular, color: color), + ), + ], + ), + ); + } +} diff --git a/lib/Peach/see/systemd/ViewSystemd.dart b/lib/Peach/see/systemd/ViewSystemd.dart new file mode 100644 index 0000000..8aa2f6e --- /dev/null +++ b/lib/Peach/see/systemd/ViewSystemd.dart @@ -0,0 +1,68 @@ +import '../../about/external/initial/SystemdSpec.dart'; +import '../../about/internal/application/ServerProfile.dart'; +import '../../about/internal/application/ServiceConfig.dart'; +import '../../about/internal/application/StepTracker.dart'; +import '../../comms/Comms.dart' show LogLine; +import '../parent/ParentViewModel.dart'; +import 'ConnectSystemd.dart'; + +class ViewSystemd extends ParentViewModel { + ConnectSystemd connection; + + ViewSystemd(super.context, this.connection); + + Stream get logs => getDataManager().logs; + + Stream> get steps => getDataManager().stepStream; + + /// Writes the unit file, reloads systemd and (optionally) enables and starts + /// it. On success the unit name is recorded against [service] so the deploy + /// and log screens target the right unit from now on. + void installUnit( + ServerProfile server, + SystemdSpec spec, + ServiceConfig? service, + ) async { + if (!await canReach(server, () => installUnit(server, spec, service))) { + return; + } + try { + final result = await getDataManager().installSystemdUnit( + server: server, + unitName: spec.safeName, + unitContent: spec.render(), + enable: spec.enableOnBoot, + startNow: spec.startNow, + ); + + if (result.ok && service != null) { + service.serviceName = spec.safeName; + await _persistService(server, service); + } + + connection.onUnitInstalled(result); + } catch (error) { + handleError( + error, + () => installUnit(server, spec, service), + () {}, + "Retry", + ); + } + } + + /// Saves the service back onto its server in storage. + Future _persistService( + ServerProfile server, + ServiceConfig service, + ) async { + final servers = await getDataManager().getServers(); + final serverIndex = servers.indexWhere((s) => s.id == server.id); + if (serverIndex < 0) return; + final services = servers[serverIndex].services; + final serviceIndex = services.indexWhere((s) => s.id == service.id); + if (serviceIndex < 0) return; + services[serviceIndex] = service; + await getDataManager().setServers(servers); + } +} diff --git a/lib/Peach/utils/Colors.dart b/lib/Peach/utils/Colors.dart new file mode 100644 index 0000000..ed99b04 --- /dev/null +++ b/lib/Peach/utils/Colors.dart @@ -0,0 +1,569 @@ +import 'package:flutter/material.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// Named palette. Every colour used anywhere in the app is named here β€” never +// write a hex literal at a call site. Brightness-aware surfaces still come from +// [JpColors] via `context.jp`; these constants are the fixed brand tones and +// the defaults used where no context is available. +// ───────────────────────────────────────────────────────────────────────────── + +var colorWhite = const Color(0xFFFFFFFF); +var colorPrimary = const Color(0xFFE8451F); // vermilion β€” brand primary +var colorPrimaryDark = const Color(0xFF111823); // near-black ink +var colorPrimaryDark2 = const Color(0xFF0E1521); +var colorPrimaryLight = const Color(0xFFF3F4F7); +var colorPrimaryLight2 = const Color(0xFFF5F6F9); + +var colorAccent = const Color(0xFF3B82F6); // blue β€” web/deploy differentiation +var colorPositive = const Color(0xFF17C79A); +var colorNegative = const Color(0xFFE5484D); +var colorWarmYellow = const Color(0xFFF5A623); +var colorViolet = const Color(0xFF8B7CFF); +var colorCyan = const Color(0xFF38BDF8); + +var colorGrey = const Color(0xFF94A3B8); +var colorGrey2 = const Color(0xFF586372); +var colorGrey3 = const Color(0xFF9AA3B0); +var colorBorder = const Color(0xFFE5E8ED); +var colorConsole = const Color(0xFF070A10); + +/// The terminal palette. Console surfaces (log output, unit-file previews, +/// command blocks) stay dark in **both** app themes β€” like a code block, they +/// don't invert β€” so these are fixed tones rather than [JpColors] entries. +/// The background itself is still `jp.consoleBg`, which the light theme lifts +/// slightly. +class ConsoleColors { + const ConsoleColors._(); + + /// Hairline frame and dividers inside a console panel. + static const frame = Color(0xFF232B36); + + /// Slightly lifted frame, for controls sitting on the console. + static const frameRaised = Color(0xFF2E3947); + + /// Primary console body copy β€” the monospace output itself. + static const body = Color(0xFFD3D8E0); + + /// Header/label copy and enabled control glyphs. + static const header = Color(0xFFB9C1CE); + + /// Secondary counters and captions. + static const muted = Color(0xFF6B7482); + + /// Disabled controls and placeholder copy. + static const disabled = Color(0xFF5A6472); + + // Log severity tones, tuned for legibility on [JpColors.consoleBg]. + static const error = Color(0xFFFF8B8B); + static const warn = Color(0xFFFFD066); + static const success = Color(0xFF3DE0AE); + static const step = Color(0xFF8AD0FF); + static const info = Color(0xFF9AA4B2); + + /// The pulsing "LIVE" indicator on a streaming console. + static const live = Color(0xFFFF6B6B); +} + +/// Builds the 50β†’900 swatch Material needs from a single brand colour. +MaterialColor createMaterialColor(Color color) { + final strengths = [.05]; + final swatch = {}; + final int r = (color.r * 255).round(), + g = (color.g * 255).round(), + b = (color.b * 255).round(); + + for (int i = 1; i < 10; i++) { + strengths.add(0.1 * i); + } + for (final strength in strengths) { + final double ds = 0.5 - strength; + swatch[(strength * 1000).round()] = Color.fromRGBO( + r + ((ds < 0 ? r : (255 - r)) * ds).round(), + g + ((ds < 0 ? g : (255 - g)) * ds).round(), + b + ((ds < 0 ? b : (255 - b)) * ds).round(), + 1, + ); + } + return MaterialColor(color.toARGB32(), swatch); +} + +/// `#RRGGBB` (or `#AARRGGBB`) β†’ [Color]. +Color getColorFromHex(String hex) { + var value = hex.replaceFirst('#', ''); + if (value.length == 6) value = 'FF$value'; + return Color(int.parse(value, radix: 16)); +} + +/// [Color] β†’ `#AARRGGBB`. +String colorToHex(Color color) => + '#${color.toARGB32().toRadixString(16).padLeft(8, '0').toUpperCase()}'; + +/// Brightness-aware neutral tokens (surfaces, borders, text). Brand accents +/// live on [PeachTheme] and read well on both light and dark. +@immutable +class JpColors extends ThemeExtension { + final Color bg; + final Color surface; + final Color surface2; + final Color border; + final Color text; + final Color textDim; + final Color textFaint; + final Color consoleBg; + + const JpColors({ + required this.bg, + required this.surface, + required this.surface2, + required this.border, + required this.text, + required this.textDim, + required this.textFaint, + required this.consoleBg, + }); + + // Deep, cool-neutral ink. More separation between each step so panels read + // crisply and hairline borders stay visible β€” the Linear/Vercel register. + static const dark = JpColors( + bg: Color(0xFF0A0E14), + surface: Color(0xFF121821), + surface2: Color(0xFF1B222D), + border: Color(0xFF283040), + text: Color(0xFFF3F5F8), + textDim: Color(0xFFAEB7C4), + textFaint: Color(0xFF6B7585), + consoleBg: Color(0xFF070A10), + ); + + // Clean, soft, premium light β€” off-white canvas, pure-white cards, crisp + // hairlines. Avoids the flat grey that reads "template". + static const light = JpColors( + bg: Color(0xFFF3F4F7), + surface: Color(0xFFFFFFFF), + surface2: Color(0xFFF5F6F9), + border: Color(0xFFE5E8ED), + text: Color(0xFF0E1521), + textDim: Color(0xFF586372), + textFaint: Color(0xFF9AA3B0), + consoleBg: Color(0xFF0E1521), + ); + + @override + JpColors copyWith({ + Color? bg, + Color? surface, + Color? surface2, + Color? border, + Color? text, + Color? textDim, + Color? textFaint, + Color? consoleBg, + }) => JpColors( + bg: bg ?? this.bg, + surface: surface ?? this.surface, + surface2: surface2 ?? this.surface2, + border: border ?? this.border, + text: text ?? this.text, + textDim: textDim ?? this.textDim, + textFaint: textFaint ?? this.textFaint, + consoleBg: consoleBg ?? this.consoleBg, + ); + + @override + JpColors lerp(ThemeExtension? other, double t) { + if (other is! JpColors) return this; + return JpColors( + bg: Color.lerp(bg, other.bg, t)!, + surface: Color.lerp(surface, other.surface, t)!, + surface2: Color.lerp(surface2, other.surface2, t)!, + border: Color.lerp(border, other.border, t)!, + text: Color.lerp(text, other.text, t)!, + textDim: Color.lerp(textDim, other.textDim, t)!, + textFaint: Color.lerp(textFaint, other.textFaint, t)!, + consoleBg: Color.lerp(consoleBg, other.consoleBg, t)!, + ); + } +} + +extension JpColorsContext on BuildContext { + JpColors get jp => Theme.of(this).extension()!; +} + +/// PeachDeployments theme: modern rounded "cockpit", light + dark. +class PeachTheme { + // "expand" brand palette: vermilion red-orange primary, ink dark, warm gray. + static const seed = Color(0xFFE8451F); // vermilion red-orange + static const ink = Color(0xFF111823); // near-black + static const accent = Color(0xFF3B82F6); // blue (web differentiation) + static const danger = Color(0xFFE5484D); + static const warn = Color(0xFFF5A623); + static const slate = Color(0xFF94A3B8); // neutral chart tone + static const violet = Color(0xFF8B7CFF); + static const mint = Color(0xFF17C79A); // fresh positive tone + static const cyan = Color(0xFF38BDF8); + static const pink = Color(0xFFEC4899); // completes the categorical set + + static const _display = 'Sora'; + static const _body = 'Urbanist'; + + // Warm brand gradient for CTAs, rings and progress. + static const brandGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF6A3D), Color(0xFFE23C16)], + ); + static const peachGradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFE0D0), Color(0xFFFFC1A3)], + ); + + static ThemeData light() => _build(Brightness.light, JpColors.light); + static ThemeData dark() => _build(Brightness.dark, JpColors.dark); + + /// A colored glow (for accent elements). + static List glow( + Color c, { + double blur = 26, + double spread = -2, + double opacity = 0.45, + }) => [ + BoxShadow( + color: c.withValues(alpha: opacity), + blurRadius: blur, + spreadRadius: spread, + offset: Offset(0, blur * 0.35), + ), + ]; + + /// Soft, layered depth shadow β€” barely-there but present, the premium + /// register (not the heavy drop shadow of template UIs). + static List softShadow(Brightness b) => b == Brightness.dark + ? const [ + BoxShadow( + color: Color(0x4D000000), + blurRadius: 28, + spreadRadius: -8, + offset: Offset(0, 18), + ), + BoxShadow( + color: Color(0x26000000), + blurRadius: 4, + offset: Offset(0, 1), + ), + ] + : const [ + BoxShadow( + color: Color(0x141B2A44), + blurRadius: 24, + spreadRadius: -6, + offset: Offset(0, 12), + ), + BoxShadow( + color: Color(0x0A1B2A44), + blurRadius: 2, + offset: Offset(0, 1), + ), + ]; + + /// A hair-lighter tint of [surface] for the top edge of a card, giving an + /// opaque "lit from above" bevel without any translucency. + static Color _topSheen(Color surface, Brightness b) => b == Brightness.dark + ? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), surface) + : surface; + + /// Elevated SOLID panel decoration used by the custom cards. Opaque, with a + /// subtle top-to-bottom sheen and a crisp hairline β€” no translucency. + static BoxDecoration panel( + BuildContext context, { + Color? glowColor, + double radius = 20, + }) { + final jp = context.jp; + final b = Theme.of(context).brightness; + return BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [_topSheen(jp.surface, b), jp.surface], + ), + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: jp.border), + boxShadow: [ + ...softShadow(b), + if (glowColor != null) ...glow(glowColor, opacity: 0.16, blur: 30), + ], + ); + } + + static ThemeData _build(Brightness b, JpColors jp) { + final base = ThemeData(brightness: b, useMaterial3: true); + final scheme = ColorScheme.fromSeed(seedColor: seed, brightness: b) + .copyWith( + surface: jp.surface, + secondary: accent, + primary: seed, + onPrimary: Colors.white, + error: danger, + ); + + final textTheme = base.textTheme + .apply(fontFamily: _body, bodyColor: jp.text, displayColor: jp.text) + .copyWith( + // Tight, confident display tracking β€” the hallmark of premium type. + displaySmall: base.textTheme.displaySmall?.copyWith( + fontFamily: _display, + fontWeight: FontWeight.w800, + letterSpacing: -0.8, + color: jp.text, + ), + headlineMedium: base.textTheme.headlineMedium?.copyWith( + fontFamily: _display, + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + color: jp.text, + ), + headlineSmall: base.textTheme.headlineSmall?.copyWith( + fontFamily: _display, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: jp.text, + ), + titleLarge: base.textTheme.titleLarge?.copyWith( + fontFamily: _display, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: jp.text, + ), + titleMedium: base.textTheme.titleMedium?.copyWith( + fontFamily: _display, + fontWeight: FontWeight.w700, + color: jp.text, + ), + bodyLarge: base.textTheme.bodyLarge?.copyWith( + height: 1.45, + color: jp.text, + ), + bodyMedium: base.textTheme.bodyMedium?.copyWith( + height: 1.45, + color: jp.textDim, + ), + labelLarge: base.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ); + + return base.copyWith( + colorScheme: scheme, + extensions: [jp], + // Transparent so the glow backdrop shows through app-wide. + scaffoldBackgroundColor: Colors.transparent, + canvasColor: jp.bg, + textTheme: textTheme, + splashFactory: InkSparkle.splashFactory, + appBarTheme: AppBarTheme( + backgroundColor: jp.bg, + foregroundColor: jp.text, + surfaceTintColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + fontFamily: _display, + fontSize: 20, + fontWeight: FontWeight.w700, + letterSpacing: -0.3, + color: jp.text, + ), + iconTheme: IconThemeData(color: jp.textDim), + ), + iconTheme: IconThemeData(color: jp.textDim), + dividerTheme: DividerThemeData(color: jp.border, thickness: 1), + listTileTheme: ListTileThemeData( + iconColor: jp.textDim, + textColor: jp.text, + ), + tabBarTheme: TabBarThemeData( + labelColor: seed, + unselectedLabelColor: jp.textFaint, + indicatorColor: seed, + indicatorSize: TabBarIndicatorSize.label, + dividerColor: jp.border, + overlayColor: WidgetStateProperty.all(seed.withValues(alpha: 0.06)), + ), + popupMenuTheme: PopupMenuThemeData( + color: jp.surface, + surfaceTintColor: Colors.transparent, + elevation: 12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: jp.border), + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: jp.surface, + surfaceTintColor: Colors.transparent, + elevation: 24, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: jp.border), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: b == Brightness.dark + ? jp.surface2 + : const Color(0xFF161D28), + contentTextStyle: const TextStyle( + color: Colors.white, + fontFamily: _body, + ), + elevation: 12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: b == Brightness.dark ? jp.border : Colors.transparent, + ), + ), + ), + cardTheme: CardThemeData( + color: jp.surface, + elevation: 0, + shadowColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide(color: jp.border), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: jp.surface2, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 14, + ), + hintStyle: TextStyle(color: jp.textFaint), + labelStyle: TextStyle(color: jp.textDim), + floatingLabelStyle: const TextStyle( + color: seed, + fontWeight: FontWeight.w700, + ), + prefixIconColor: jp.textFaint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(13), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(13), + borderSide: BorderSide(color: jp.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(13), + borderSide: const BorderSide(color: seed, width: 1.6), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: + FilledButton.styleFrom( + backgroundColor: seed, + foregroundColor: Colors.white, + disabledBackgroundColor: seed.withValues(alpha: 0.32), + disabledForegroundColor: Colors.white70, + elevation: 0, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + textStyle: const TextStyle( + fontFamily: _body, + fontWeight: FontWeight.w700, + fontSize: 14, + letterSpacing: 0.2, + ), + ).copyWith( + // Subtle brand shadow only when the button is actually enabled. + elevation: WidgetStateProperty.resolveWith( + (s) => s.contains(WidgetState.disabled) ? 0 : 6, + ), + shadowColor: WidgetStateProperty.all( + seed.withValues(alpha: 0.45), + ), + overlayColor: WidgetStateProperty.all( + Colors.white.withValues(alpha: 0.10), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: jp.text, + side: BorderSide(color: jp.border), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13), + ), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15), + textStyle: const TextStyle( + fontFamily: _body, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: seed, + textStyle: const TextStyle( + fontFamily: _body, + fontWeight: FontWeight.w700, + ), + ), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: seed, + foregroundColor: Colors.white, + elevation: 8, + highlightElevation: 10, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + chipTheme: base.chipTheme.copyWith( + backgroundColor: jp.surface2, + side: BorderSide(color: jp.border), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)), + labelStyle: TextStyle(color: jp.textDim, fontWeight: FontWeight.w600), + ), + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith( + (s) => s.contains(WidgetState.selected) ? seed : null, + ), + trackColor: WidgetStateProperty.resolveWith( + (s) => s.contains(WidgetState.selected) + ? seed.withValues(alpha: 0.4) + : null, + ), + ), + progressIndicatorTheme: ProgressIndicatorThemeData( + color: seed, + linearTrackColor: jp.surface2, + circularTrackColor: Colors.transparent, + ), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: b == Brightness.dark ? jp.surface2 : const Color(0xFF161D28), + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: b == Brightness.dark ? jp.border : Colors.transparent, + ), + ), + textStyle: const TextStyle( + color: Colors.white, + fontFamily: _body, + fontSize: 12, + ), + ), + ); + } +} diff --git a/lib/Peach/utils/CommonUtils.dart b/lib/Peach/utils/CommonUtils.dart new file mode 100644 index 0000000..e273340 --- /dev/null +++ b/lib/Peach/utils/CommonUtils.dart @@ -0,0 +1,69 @@ +/// Shared formatting helpers. Anything a screen would otherwise write inline +/// to render a value belongs here, so the same quantity reads the same way +/// everywhere in the app. +library; + +/// Human-friendly "time ago" formatting. +String timeAgo(DateTime t) { + final d = DateTime.now().difference(t); + if (d.inSeconds < 45) return 'just now'; + if (d.inMinutes < 60) return '${d.inMinutes}m ago'; + if (d.inHours < 24) return '${d.inHours}h ago'; + if (d.inDays < 7) return '${d.inDays}d ago'; + String two(int n) => n.toString().padLeft(2, '0'); + return '${t.year}-${two(t.month)}-${two(t.day)}'; +} + +/// Byte count as B / KB / MB / GB. +String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; +} + +/// An integer with thousands separators β€” offsets and message counts are +/// unreadable without them. +String formatCount(int value) { + final digits = value.abs().toString(); + final out = StringBuffer(value < 0 ? '-' : ''); + for (var i = 0; i < digits.length; i++) { + if (i > 0 && (digits.length - i) % 3 == 0) out.write(','); + out.write(digits[i]); + } + return out.toString(); +} + +/// `HH:MM` for a timestamp. +String formatClock(DateTime t) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${two(t.hour)}:${two(t.minute)}'; +} + +/// The last segment of a path, accepting either separator and ignoring a +/// trailing slash. +String baseName(String path) { + final normalised = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), ''); + final i = normalised.lastIndexOf('/'); + return i >= 0 ? normalised.substring(i + 1) : normalised; +} + +/// Joins a POSIX directory and a child name without doubling the separator. +String joinRemote(String base, String name) { + final trimmed = base.endsWith('/') + ? base.substring(0, base.length - 1) + : base; + return '$trimmed/$name'; +} + +/// The parent of a POSIX path, bottoming out at `/`. +String parentRemote(String path) { + final trimmed = path.endsWith('/') && path.length > 1 + ? path.substring(0, path.length - 1) + : path; + final i = trimmed.lastIndexOf('/'); + if (i <= 0) return '/'; + return trimmed.substring(0, i); +} diff --git a/lib/Peach/utils/Validators.dart b/lib/Peach/utils/Validators.dart new file mode 100644 index 0000000..9127d10 --- /dev/null +++ b/lib/Peach/utils/Validators.dart @@ -0,0 +1,26 @@ +// Form validators. Screens reference these rather than writing closures +// inline, so an error message reads the same wherever the rule applies. + +String? requiredField(String? value) => + (value == null || value.trim().isEmpty) ? 'Required' : null; + +String? validPort(String? value) { + if (value == null || value.trim().isEmpty) return null; // falls back to 22 + final port = int.tryParse(value.trim()); + if (port == null) return 'Numbers only'; + if (port < 1 || port > 65535) return 'Must be 1–65535'; + return null; +} + +String? validHost(String? value) { + if (value == null || value.trim().isEmpty) return 'Required'; + if (value.trim().contains(' ')) return 'No spaces allowed'; + return null; +} + +/// An absolute POSIX path, as every remote target must be. +String? validRemotePath(String? value) { + if (value == null || value.trim().isEmpty) return 'Required'; + if (!value.trim().startsWith('/')) return 'Must start with /'; + return null; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..3759af7 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'Peach/configs/ThemeController.dart'; +import 'Peach/designs/Backdrop.dart'; +import 'Peach/see/splash/Splash.dart'; +import 'Peach/utils/Colors.dart'; + +void main() { + runApp(const Peach()); +} + +/// The app shell: theme, the glow backdrop, and the splash that loads stored +/// state before handing over to Home. Everything else lives under +/// `lib/Peach/see/` as a four-file screen. +class Peach extends StatelessWidget { + const Peach({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: ThemeController.mode, + builder: (context, mode, child) => MaterialApp( + title: 'Peach', + debugShowCheckedModeBanner: false, + theme: PeachTheme.light(), + darkTheme: PeachTheme.dark(), + themeMode: mode, + builder: (context, child) => Backdrop(child: child ?? const SizedBox()), + home: const Splash(), + ), + ); + } +} diff --git a/test/build_service_test.dart b/test/build_service_test.dart new file mode 100644 index 0000000..1c2fb83 --- /dev/null +++ b/test/build_service_test.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:peach/Peach/about/internal/application/ServiceConfig.dart'; +import 'package:peach/Peach/about/internal/file/BuildService.dart'; + +void main() { + test('runs custom build command and picks the runnable jar', () async { + // Fake Gradle project with several jars in build/libs. + final dir = Directory.systemTemp.createTempSync('peachdeploy_build_test'); + addTearDown(() => dir.deleteSync(recursive: true)); + + File('${dir.path}/build.gradle').writeAsStringSync('// fake'); + final libs = Directory('${dir.path}/build/libs')..createSync(recursive: true); + // Distractors that must be excluded, plus the real artifact. + File('${libs.path}/app-sources.jar').writeAsStringSync('x'); + File('${libs.path}/app-plain.jar').writeAsStringSync('x'); + File('${libs.path}/app.jar').writeAsStringSync('runnable'); + + final svc = ServiceConfig( + name: 'demo', + localProjectPath: dir.path, + // Trivial command so we don't need a real Gradle install. + buildCommand: Platform.isWindows ? 'echo building' : 'true', + ); + + final build = BuildService(); + addTearDown(build.dispose); + + final result = await build.build(svc); + + expect(result.ok, isTrue, reason: result.message); + expect(result.jar, isNotNull); + expect(result.jar!.path.replaceAll('\\', '/'), endsWith('app.jar')); + }); + + test('reports a helpful error when no project is detected', () async { + final dir = Directory.systemTemp.createTempSync('peachdeploy_empty_test'); + addTearDown(() => dir.deleteSync(recursive: true)); + + final svc = ServiceConfig(name: 'empty', localProjectPath: dir.path); + final build = BuildService(); + addTearDown(build.dispose); + + final result = await build.build(svc); + expect(result.ok, isFalse); + expect(result.message.toLowerCase(), contains('detect')); + }); +} diff --git a/test/tools/generate_icon_test.dart b/test/tools/generate_icon_test.dart new file mode 100644 index 0000000..0f145e0 --- /dev/null +++ b/test/tools/generate_icon_test.dart @@ -0,0 +1,197 @@ +// Renders the Peach app icon to assets/icon/ using Flutter's software +// rasterizer (full anti-aliasing, no external image tools needed). +// +// flutter test test/tools/generate_icon_test.dart +// +// Then apply everywhere with: dart run flutter_launcher_icons +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _size = 1024.0; + +// Brand gradient (matches PeachTheme.brandGradient / the splash tile). +const _g1 = Color(0xFFFF6A3D); +const _g2 = Color(0xFFE23C16); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('generate app icon', () async { + // Full-bleed source (square gradient, no alpha) β€” iOS-safe, universal. + await _write('assets/icon/app_icon.png', await _render(_Mode.bleed)); + + // Rounded-tile variant with transparent margin (macOS / marketing). + await _write('assets/icon/app_icon_tile.png', await _render(_Mode.tile)); + + // Android adaptive-icon foreground: rocket only, padded into the safe zone. + await _write('assets/icon/app_icon_fg.png', await _render(_Mode.foreground)); + }); +} + +enum _Mode { bleed, tile, foreground } + +Future _write(String path, Uint8List bytes) async { + final f = File(path); + await f.create(recursive: true); + await f.writeAsBytes(bytes); + // ignore: avoid_print + print('wrote $path (${bytes.length} bytes)'); +} + +Future _render(_Mode mode) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + const rect = Rect.fromLTWH(0, 0, _size, _size); + + // ---- background tile ---- + final bg = Paint() + ..shader = ui.Gradient.linear( + const Offset(0, 0), + const Offset(_size, _size), + [_g1, _g2], + ); + + switch (mode) { + case _Mode.bleed: + canvas.drawRect(rect, bg); + _sheen(canvas); + break; + case _Mode.tile: + const margin = 96.0; + final tileR = RRect.fromRectAndRadius( + const Rect.fromLTWH( + margin, margin, _size - 2 * margin, _size - 2 * margin), + const Radius.circular(224), + ); + canvas.drawRRect(tileR, bg); + canvas.drawRRect( + tileR.deflate(3), + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..color = Colors.white.withValues(alpha: 0.16), + ); + _sheen(canvas); + break; + case _Mode.foreground: + // transparent background β€” foreground carries the mark only + break; + } + + // The mark: filled confidently. The adaptive foreground fills ~2/3 of the + // frame (the launcher XML then adds a 16% safe-zone inset on top). + final scale = mode == _Mode.foreground ? 1.15 : 1.14; + canvas.save(); + canvas.translate(512, 524); + canvas.scale(scale); + canvas.translate(-512, -524); + _drawRocket(canvas); + canvas.restore(); + + final pic = recorder.endRecording(); + final img = await pic.toImage(_size.toInt(), _size.toInt()); + final data = await img.toByteData(format: ui.ImageByteFormat.png); + return data!.buffer.asUint8List(); +} + +/// Soft top-left highlight for depth. +void _sheen(Canvas canvas) { + canvas.drawCircle( + const Offset(300, 250), + 620, + Paint() + ..shader = ui.Gradient.radial( + const Offset(300, 250), + 620, + [Colors.white.withValues(alpha: 0.16), Colors.white.withValues(alpha: 0)], + ), + ); +} + +/// A clean, upright rocket β€” the Peach mark β€” in white with an accent window, +/// fins and an exhaust flame. Centered on the 1024 canvas. +void _drawRocket(Canvas canvas) { + const cx = 512.0; + + // subtle drop shadow behind the whole mark + canvas.save(); + canvas.translate(0, 16); + _rocketBodyAndFins(canvas, Paint() + ..color = Colors.black.withValues(alpha: 0.16) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 22)); + canvas.restore(); + + // flame first (behind body base) + final flame = Path() + ..moveTo(cx, 828) + ..cubicTo(cx - 62, 748, cx - 46, 712, cx - 30, 706) + ..lineTo(cx + 30, 706) + ..cubicTo(cx + 46, 712, cx + 62, 748, cx, 828) + ..close(); + canvas.drawPath( + flame, + Paint() + ..shader = ui.Gradient.linear( + const Offset(cx, 700), + const Offset(cx, 828), + [const Color(0xFFFFD48A), const Color(0xFFF5A623)], + )); + // inner flame + final flame2 = Path() + ..moveTo(cx, 790) + ..cubicTo(cx - 26, 742, cx - 20, 720, cx - 12, 716) + ..lineTo(cx + 12, 716) + ..cubicTo(cx + 20, 720, cx + 26, 742, cx, 790) + ..close(); + canvas.drawPath(flame2, Paint()..color = Colors.white.withValues(alpha: 0.85)); + + // white body + fins + _rocketBodyAndFins(canvas, Paint()..color = Colors.white); + + // window (accent blue with a soft rim) + canvas.drawCircle(const Offset(cx, 432), 70, + Paint()..color = const Color(0xFF1F2937).withValues(alpha: 0.10)); + canvas.drawCircle(const Offset(cx, 430), 66, + Paint() + ..shader = ui.Gradient.linear( + const Offset(cx - 60, 370), + const Offset(cx + 60, 490), + [const Color(0xFF5AA0FF), const Color(0xFF2F6FE0)], + )); + // window glint + canvas.drawCircle(const Offset(cx - 20, 408), 18, + Paint()..color = Colors.white.withValues(alpha: 0.55)); +} + +void _rocketBodyAndFins(Canvas canvas, Paint paint) { + const cx = 512.0; + + // fins (drawn under the body edges) + final rightFin = Path() + ..moveTo(cx + 96, 556) + ..lineTo(cx + 208, 712) + ..lineTo(cx + 78, 676) + ..close(); + final leftFin = Path() + ..moveTo(cx - 96, 556) + ..lineTo(cx - 208, 712) + ..lineTo(cx - 78, 676) + ..close(); + canvas.drawPath(rightFin, paint); + canvas.drawPath(leftFin, paint); + + // body (symmetric capsule with a pointed nose) + final body = Path() + ..moveTo(cx, 244) // nose tip + ..cubicTo(cx + 92, 300, cx + 116, 372, cx + 116, 452) + ..cubicTo(cx + 116, 566, cx + 96, 646, cx + 60, 700) + ..cubicTo(cx + 34, 714, cx - 34, 714, cx - 60, 700) + ..cubicTo(cx - 96, 646, cx - 116, 566, cx - 116, 452) + ..cubicTo(cx - 116, 372, cx - 92, 300, cx, 244) + ..close(); + canvas.drawPath(body, paint); +} diff --git a/test/web_build_test.dart b/test/web_build_test.dart new file mode 100644 index 0000000..a6ae3d1 --- /dev/null +++ b/test/web_build_test.dart @@ -0,0 +1,74 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:peach/Peach/about/internal/application/ServiceConfig.dart'; +import 'package:peach/Peach/about/internal/file/BuildService.dart'; +import 'package:peach/Peach/about/internal/file/WebArchive.dart'; + +void main() { + test('buildWeb locates the React build/ output', () async { + final dir = Directory.systemTemp.createTempSync('peachdeploy_web_test'); + addTearDown(() => dir.deleteSync(recursive: true)); + + Directory('${dir.path}/build').createSync(recursive: true); + File('${dir.path}/build/index.html').writeAsStringSync(''); + File('${dir.path}/build/main.js').writeAsStringSync('console.log(1)'); + + final svc = ServiceConfig( + name: 'site', + kind: ServiceKind.webApp, + framework: WebFramework.react, + localProjectPath: dir.path, + buildCommand: Platform.isWindows ? 'echo building' : 'true', + ); + + final build = BuildService(); + addTearDown(build.dispose); + + final result = await build.buildWeb(svc); + expect(result.ok, isTrue, reason: result.message); + expect(result.dir, isNotNull); + expect(result.dir!.path.replaceAll('\\', '/'), endsWith('build')); + }); + + test('static framework needs no build and publishes the folder', () async { + final dir = Directory.systemTemp.createTempSync('peachdeploy_static_test'); + addTearDown(() => dir.deleteSync(recursive: true)); + File('${dir.path}/index.html').writeAsStringSync(''); + + final svc = ServiceConfig( + name: 'static', + kind: ServiceKind.webApp, + framework: WebFramework.staticFiles, + localProjectPath: dir.path, + ); + final build = BuildService(); + addTearDown(build.dispose); + + final result = await build.buildWeb(svc); + expect(result.ok, isTrue, reason: result.message); + expect(result.dir, isNotNull); + }); + + test('WebArchive tar.gz round-trips files at the archive root', () { + final dir = Directory.systemTemp.createTempSync('peachdeploy_arch_test'); + addTearDown(() => dir.deleteSync(recursive: true)); + File('${dir.path}/index.html').writeAsStringSync('hello'); + Directory('${dir.path}/assets').createSync(); + File('${dir.path}/assets/app.js').writeAsStringSync('x = 1;'); + + final (bytes, count) = WebArchive.tarGz(dir); + expect(count, 2); + + final tar = GZipDecoder().decodeBytes(bytes); + final archive = TarDecoder().decodeBytes(tar); + final names = archive.files.map((f) => f.name).toSet(); + // Paths must be POSIX (forward slashes) so `tar` on Linux rebuilds the + // directory tree instead of creating literal `assets\app.js` files. + expect(names.any((n) => n.contains('\\')), isFalse, + reason: 'archive entries must use forward slashes'); + expect(names, contains('index.html')); + expect(names, contains('assets/app.js')); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..ca8b32b --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:peach/main.dart'; + +void main() { + testWidgets('Peach shows the branded splash on boot', (tester) async { + await tester.pumpWidget(const Peach()); + // While storage loads, the splash screen is shown. + expect(find.text('Peach'), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsWidgets); + }); +}