diff --git a/CHANGELOG.md b/CHANGELOG.md index c02bea08..17d09f6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.38] — 2026-06-23 + +### Added +- **Docker panel completion** — the Containers screen now has dedicated **Docker** and **Compose** tabs alongside Kubernetes. Docker (`DockerPanel`): a live container list (`docker ps -a`) with per-container **Stop / Start / Restart**, one-click **Exec** into a new SSH tab, and an inline follow-mode **log viewer** (`docker logs -f --tail`, auto-scroll with manual-scroll detach, 2000-line cap). Compose (`ComposePanel`): stack discovery via `docker compose ls` merged with a `find` sweep of `~ /opt /srv /home` (ls entries take precedence) plus a manual compose-file path add; per-stack **Up / Down**, per-service **Start / Stop**, service replica counts, and per-service follow-mode logs. All commands run with `auditSource: 'devops'` and single-quote project paths; non-zero exits surface the real stderr. + +--- + ## [0.1.37] — 2026-06-18 ### Added diff --git a/app/lib/models/container_entry.dart b/app/lib/models/container_entry.dart index e4a04c5c..af77f3d1 100644 --- a/app/lib/models/container_entry.dart +++ b/app/lib/models/container_entry.dart @@ -89,3 +89,31 @@ class K8sForwardHandle { } } } + +/// One Docker Compose project discovered on the remote host. +class ComposeStack { + final String name; + final String projectDir; + final String status; + + const ComposeStack({ + required this.name, + required this.projectDir, + required this.status, + }); +} + +/// One service within a Docker Compose stack. +class ComposeService { + final String name; + final String status; + final String image; + final int replicas; + + const ComposeService({ + required this.name, + required this.status, + required this.image, + this.replicas = 1, + }); +} diff --git a/app/lib/services/container_service.dart b/app/lib/services/container_service.dart index 4226d31b..593033f1 100644 --- a/app/lib/services/container_service.dart +++ b/app/lib/services/container_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math'; @@ -19,13 +20,41 @@ class ContainerService { static const _dockerFormat = '{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}'; Future> listDockerContainers(Host host) async { - final r = await ssh.exec(host, "docker ps --format '$_dockerFormat'", auditSource: 'devops'); + final r = await ssh.exec(host, "docker ps -a --format '$_dockerFormat'", auditSource: 'devops'); if (r.exitCode != 0) { throw Exception(r.stderr.trim().isEmpty ? 'docker ps failed' : r.stderr.trim()); } return parseDockerPs(r.stdout); } + // ── Docker logs + lifecycle ─────────────────────────── + + /// Streams stdout+stderr of `docker logs -f `. + Stream streamDockerLogs(Host host, String id, {int tail = 100}) => + ssh.execStream(host, 'docker logs -f --tail=$tail ${_shq(id)} 2>&1', + auditSource: 'devops'); + + Future stopContainer(Host host, String id) async { + final r = await ssh.exec(host, 'docker stop ${_shq(id)}', auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception(r.stderr.trim().isEmpty ? 'docker stop failed' : r.stderr.trim()); + } + } + + Future startContainer(Host host, String id) async { + final r = await ssh.exec(host, 'docker start ${_shq(id)}', auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception(r.stderr.trim().isEmpty ? 'docker start failed' : r.stderr.trim()); + } + } + + Future restartContainer(Host host, String id) async { + final r = await ssh.exec(host, 'docker restart ${_shq(id)}', auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception(r.stderr.trim().isEmpty ? 'docker restart failed' : r.stderr.trim()); + } + } + // ── Kubernetes ──────────────────────────────────────── Future> listPods( Host host, { @@ -288,12 +317,16 @@ class ContainerService { return RuntimeAvailability.available; } + // ── Shell quoting ───────────────────────────────────── + /// POSIX single-quote-escapes [s] for safe shell interpolation. + static String _shq(String s) => "'${s.replaceAll("'", r"'\''")}'"; + // ── Exec command builders ───────────────────────────── static const _shFallback = "sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'"; static String dockerExecCommand(String id) => - 'docker exec -it $id $_shFallback'; + 'docker exec -it ${_shq(id)} $_shFallback'; static String kubectlExecCommand( String pod, String namespace, String? container) { @@ -321,4 +354,238 @@ class ContainerService { } return 'Check your kubeconfig / RBAC permissions.'; } + + // ── Compose static parsers ──────────────────────────── + + /// Parses `docker compose ls --format json` output. + /// Returns [] on any parse failure. + static List parseComposeLs(String json) { + if (json.trim().isEmpty) return const []; + try { + final list = jsonDecode(json) as List; + return list.map((e) { + final map = e as Map; + final configFiles = (map['ConfigFiles'] as String? ?? '').trim(); + // ConfigFiles may be comma-separated; take the first to derive projectDir. + final firstFile = configFiles.split(',').first.trim(); + final projectDir = firstFile.contains('/') + ? firstFile.substring(0, firstFile.lastIndexOf('/')) + : ''; + return ComposeStack( + name: (map['Name'] as String? ?? '').trim(), + projectDir: projectDir, + status: (map['Status'] as String? ?? '').trim(), + ); + }).where((s) => s.name.isNotEmpty && s.projectDir.isNotEmpty).toList(); + } catch (_) { + return const []; + } + } + + /// Parses `find ... -name "docker-compose.yml" -o -name "compose.yml"` output. + /// Deduplicates by projectDir. + static List parseComposeFindOutput(String stdout) { + final seen = {}; + final out = []; + for (final line in stdout.split('\n')) { + final path = line.trim(); + if (path.isEmpty) continue; + final lastSlash = path.lastIndexOf('/'); + if (lastSlash < 0) continue; + final projectDir = path.substring(0, lastSlash); + if (projectDir.isEmpty) continue; + if (seen.contains(projectDir)) continue; + seen.add(projectDir); + final name = projectDir.substring(projectDir.lastIndexOf('/') + 1); + out.add(ComposeStack(name: name, projectDir: projectDir, status: 'unknown')); + } + return out; + } + + /// Derives a [ComposeStack] from an absolute compose-file path (used by the + /// manual "add by path" flow). Returns null when [path] is not absolute. + /// A root-level file (`/compose.yml`) maps to projectDir `/`, never the empty + /// string — an empty projectDir would make `cd '' && docker compose …` run in + /// the login dir against the wrong project. + static ComposeStack? composeStackFromPath(String path) { + final p = path.trim(); + if (!p.startsWith('/')) return null; + final lastSlash = p.lastIndexOf('/'); + final projectDir = lastSlash == 0 ? '/' : p.substring(0, lastSlash); + final name = + projectDir == '/' ? '/' : projectDir.substring(projectDir.lastIndexOf('/') + 1); + return ComposeStack(name: name, projectDir: projectDir, status: 'unknown'); + } + + /// Parses `docker compose ps --format json` output (JSON array or JSONL). + /// Groups by service name, counts replicas. + static List parseComposePs(String stdout) { + if (stdout.trim().isEmpty) return const []; + final items = >[]; + try { + // Try JSON array first. + final decoded = jsonDecode(stdout.trim()); + if (decoded is List) { + for (final e in decoded) { + if (e is Map) items.add(e); + } + } + } catch (_) { + // Fall through to JSONL. + } + if (items.isEmpty) { + // Try JSONL (one JSON object per line). + for (final line in stdout.split('\n')) { + final t = line.trim(); + if (t.isEmpty) continue; + try { + final obj = jsonDecode(t); + if (obj is Map) items.add(obj); + } catch (_) { + continue; + } + } + } + if (items.isEmpty) return const []; + + // Group by "Service" field, count replicas. + final byService = >>{}; + for (final item in items) { + final svc = (item['Service'] as String? ?? '').trim(); + if (svc.isEmpty) continue; + (byService[svc] ??= []).add(item); + } + return byService.entries.map((e) { + final group = e.value; + final first = group.first; + final states = group.map((m) => (m['State'] as String? ?? '').trim()).toList(); + final String status; + if (states.every((s) => s == 'running')) { + status = 'running'; + } else if (states.any((s) => s == 'running')) { + status = 'degraded'; + } else { + // First non-empty state (a missing/blank State field would otherwise + // render a blank status label and offer the wrong lifecycle action). + status = states.firstWhere((s) => s.isNotEmpty, orElse: () => 'unknown'); + } + return ComposeService( + name: e.key, + status: status, + image: (first['Image'] as String? ?? '').trim(), + replicas: group.length, + ); + }).toList(); + } + + // ── Compose ─────────────────────────────────────────── + + /// Discovers Compose stacks by running `docker compose ls` (active projects) + /// and `find` (files on disk). Results are merged; ls entries take precedence + /// when the same projectDir appears in both. + Future> discoverComposeStacks(Host host) async { + final results = await Future.wait([ + _composeLsStacks(host), + _composeFindStacks(host), + ]); + final lsStacks = results[0]; + final findStacks = results[1]; + // Merge: ls entries take precedence. + final seen = {for (final s in lsStacks) s.projectDir}; + return [ + ...lsStacks, + ...findStacks.where((s) => !seen.contains(s.projectDir)), + ]; + } + + Future> _composeLsStacks(Host host) async { + final r = await ssh.exec(host, 'docker compose ls --format json 2>/dev/null', + auditSource: 'devops'); + if (r.exitCode != 0) return const []; + return parseComposeLs(r.stdout); + } + + Future> _composeFindStacks(Host host) async { + const findCmd = + r"find ~ /opt /srv /home -maxdepth 3 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null"; + final r = await ssh.exec(host, findCmd, auditSource: 'devops'); + // `find` exits non-zero if ANY starting root is missing (e.g. no /srv or + // /opt) even though it printed matches from the roots that do exist, so we + // parse stdout regardless of exit code. parseComposeFindOutput is robust to + // empty/garbage input (returns []), so a genuine failure still degrades safely. + return parseComposeFindOutput(r.stdout); + } + + Future> listComposeServices( + Host host, ComposeStack stack) async { + final r = await ssh.exec( + host, "cd ${_shq(stack.projectDir)} && docker compose ps --format json 2>/dev/null", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose ps failed' : r.stderr.trim()); + } + return parseComposePs(r.stdout); + } + + Future composeUp(Host host, ComposeStack stack) async { + final r = await ssh.exec( + host, "cd ${_shq(stack.projectDir)} && docker compose up -d", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose up failed' : r.stderr.trim()); + } + } + + Future composeDown(Host host, ComposeStack stack) async { + final r = await ssh.exec( + host, "cd ${_shq(stack.projectDir)} && docker compose down", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose down failed' : r.stderr.trim()); + } + } + + Future startComposeService( + Host host, ComposeStack stack, String service) async { + final r = await ssh.exec( + host, "cd ${_shq(stack.projectDir)} && docker compose start ${_shq(service)}", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose start failed' : r.stderr.trim()); + } + } + + Future stopComposeService( + Host host, ComposeStack stack, String service) async { + final r = await ssh.exec( + host, "cd ${_shq(stack.projectDir)} && docker compose stop ${_shq(service)}", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose stop failed' : r.stderr.trim()); + } + } + + Stream streamComposeServiceLogs( + Host host, ComposeStack stack, String service, {int tail = 100}) => + ssh.execStream( + host, + "cd ${_shq(stack.projectDir)} && docker compose logs -f --tail=$tail ${_shq(service)} 2>&1", + auditSource: 'devops'); + + /// Validates a Compose file at [path] by listing its services. Returns the + /// service names; throws on a non-Compose / invalid file. + Future> validateComposeFile(Host host, String path) async { + final r = await ssh.exec(host, 'docker compose -f ${_shq(path)} config --services 2>&1', + auditSource: 'devops'); + if (r.exitCode != 0) { + final detail = r.stdout.trim(); + throw Exception(detail.isEmpty ? 'Not a valid Compose file: $path' : 'Not a valid Compose file: $path — $detail'); + } + return r.stdout.split('\n').map((l) => l.trim()).where((l) => l.isNotEmpty).toList(); + } } diff --git a/app/lib/widgets/compose_panel.dart b/app/lib/widgets/compose_panel.dart new file mode 100644 index 00000000..847a3b41 --- /dev/null +++ b/app/lib/widgets/compose_panel.dart @@ -0,0 +1,483 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/container_entry.dart'; +import '../models/host.dart'; +import '../services/container_service.dart'; +import '../theme/app_theme.dart'; + +class ComposePanel extends StatefulWidget { + const ComposePanel({super.key, required this.host, required this.service}); + + final Host host; + final ContainerService service; + + @override + State createState() => _ComposePanelState(); +} + +class _ComposePanelState extends State { + List _stacks = []; + bool _loadingStacks = false; + String? _stacksError; + + ComposeStack? _selectedStack; + List _services = []; + bool _loadingServices = false; + + // Log panel + ComposeService? _logService; + StreamSubscription? _logSub; + final List _logLines = []; + final ScrollController _logScroll = ScrollController(); + bool _autoScroll = true; + + // Per-action loading + final Map _actionLoading = {}; + + // Generation counter for _selectStack to cancel stale fetches. + int _servicesGen = 0; + + // Manual path input + final TextEditingController _manualCtrl = TextEditingController(); + bool _showManualInput = false; + + // Stacks added by absolute path; kept across discovery refreshes (discovery + // only scans a fixed set of roots, so these would otherwise vanish). + final List _manualStacks = []; + + @override + void initState() { + super.initState(); + _logScroll.addListener(() { + final pos = _logScroll.position; + _autoScroll = pos.pixels >= pos.maxScrollExtent - 8; + }); + _loadStacks(); + } + + @override + void dispose() { + _logSub?.cancel(); + _logScroll.dispose(); + _manualCtrl.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (!_autoScroll) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_logScroll.hasClients) { + _logScroll.jumpTo(_logScroll.position.maxScrollExtent); + } + }); + } + + void _showError(Object e) { + if (!mounted) return; + final s = e.toString(); + final msg = s.length > 200 ? '${s.substring(0, 200)}…' : s; + AppSnack.error(context, msg); + } + + Future _loadStacks() async { + setState(() { + _loadingStacks = true; + _stacksError = null; + }); + try { + final discovered = await widget.service.discoverComposeStacks(widget.host); + _stacks = [ + ...discovered, + ..._manualStacks + .where((m) => !discovered.any((d) => d.projectDir == m.projectDir)), + ]; + } catch (e) { + _stacksError = e.toString(); + } finally { + if (mounted) setState(() => _loadingStacks = false); + } + } + + Future _selectStack(ComposeStack stack) async { + _closeLogs(); + // Invalidate any in-flight service fetch up front, including on the collapse + // path, so a stale result can't repopulate _services after deselection. + final gen = ++_servicesGen; + if (_selectedStack?.projectDir == stack.projectDir) { + setState(() { + _selectedStack = null; + _services = []; + }); + return; + } + setState(() { + _selectedStack = stack; + _services = []; + _loadingServices = true; + }); + try { + final svcs = await widget.service.listComposeServices(widget.host, stack); + if (mounted && gen == _servicesGen) setState(() => _services = svcs); + } catch (e) { + _showError(e); + } finally { + if (mounted && gen == _servicesGen) setState(() => _loadingServices = false); + } + } + + Future _stackAction( + ComposeStack stack, String key, Future Function() action) async { + setState(() => _actionLoading[key] = true); + try { + await action(); + if (!mounted) return; + await _loadStacks(); + if (!mounted) return; + // If the previously selected stack is gone, clear selection. + if (_selectedStack != null && + !_stacks.any((s) => s.projectDir == _selectedStack!.projectDir)) { + setState(() { + _selectedStack = null; + _services = []; + }); + _closeLogs(); + } else if (_selectedStack?.projectDir == stack.projectDir) { + final gen = _servicesGen; + final svcs = await widget.service.listComposeServices(widget.host, stack); + if (mounted && gen == _servicesGen) setState(() => _services = svcs); + } + } catch (e) { + _showError(e); + } finally { + if (mounted) setState(() => _actionLoading.remove(key)); + } + } + + void _openServiceLogs(ComposeService svc) { + final stack = _selectedStack; + if (stack == null) return; + _logSub?.cancel(); + setState(() { + _logService = svc; + _logLines.clear(); + _autoScroll = true; + }); + _logSub = widget.service + .streamComposeServiceLogs(widget.host, stack, svc.name) + .listen((line) { + if (mounted) { + setState(() { + _logLines.add(line); + if (_logLines.length > 2000) { + _logLines.removeRange(0, _logLines.length - 2000); + } + }); + _scrollToBottom(); + } + }, onDone: () { + if (mounted) { + setState(() => _logLines.add('— connection closed —')); + _scrollToBottom(); + } + }, onError: (e) { + if (mounted) { + setState(() => _logLines.add('— error: $e —')); + _scrollToBottom(); + } + }); + } + + void _closeLogs() { + _logSub?.cancel(); + _logSub = null; + setState(() { + _logService = null; + _logLines.clear(); + }); + } + + Future _addManualPath() async { + final path = _manualCtrl.text.trim(); + if (path.isEmpty) return; + final stack = ContainerService.composeStackFromPath(path); + if (stack == null) { + _showError('Enter an absolute path to a compose file'); + return; + } + setState(() => _actionLoading['manual'] = true); + try { + await widget.service.validateComposeFile(widget.host, path); + if (!mounted) return; + setState(() { + if (!_manualStacks.any((s) => s.projectDir == stack.projectDir)) { + _manualStacks.add(stack); + } + if (!_stacks.any((s) => s.projectDir == stack.projectDir)) { + _stacks = [..._stacks, stack]; + } + _showManualInput = false; + _manualCtrl.clear(); + }); + } catch (e) { + _showError(e); + } finally { + if (mounted) setState(() => _actionLoading.remove('manual')); + } + } + + @override + Widget build(BuildContext context) { + final hasLogs = _logService != null; + return Column(children: [ + // Toolbar + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 4), + child: Row(children: [ + const Text('Compose Stacks', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), + const Spacer(), + IconButton( + tooltip: 'Add path manually', + icon: const Icon(Icons.add, size: 16), + onPressed: () => + setState(() => _showManualInput = !_showManualInput), + ), + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh, size: 16), + onPressed: _loadStacks, + ), + ]), + ), + // Manual path input + if (_showManualInput) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(children: [ + Expanded( + child: TextField( + controller: _manualCtrl, + style: const TextStyle(fontSize: 13), + decoration: const InputDecoration( + hintText: '/path/to/docker-compose.yml', + isDense: true, + contentPadding: + EdgeInsets.symmetric(horizontal: 8, vertical: 6), + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _addManualPath(), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _actionLoading['manual'] == true ? null : _addManualPath, + child: const Text('Add'), + ), + ]), + ), + // Stack list + Expanded( + flex: _selectedStack != null ? 1 : 3, + child: _buildStackList(), + ), + // Service list + if (_selectedStack != null) ...[ + const Divider(height: 1, color: AppColors.border), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row(children: [ + Text('Services in ${_selectedStack!.name}', + style: const TextStyle( + fontSize: 11, color: AppColors.textSecondary)), + ]), + ), + Expanded( + flex: 1, + child: _loadingServices + ? const Center(child: CircularProgressIndicator()) + : _buildServiceList(), + ), + ], + // Log panel + if (hasLogs) ...[ + const Divider(height: 1, color: AppColors.border), + _buildLogPanel(), + ], + ]); + } + + Widget _buildStackList() { + if (_loadingStacks) return const Center(child: CircularProgressIndicator()); + if (_stacksError != null) { + return Center(child: Text(_stacksError!, textAlign: TextAlign.center)); + } + if (_stacks.isEmpty) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.folder_open, size: 36, color: AppColors.textTertiary), + const SizedBox(height: 8), + const Text('No Compose stacks found.'), + const SizedBox(height: 4), + const Text('Tap + to add a path manually.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ]), + ); + } + return ListView.separated( + itemCount: _stacks.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: AppColors.border), + itemBuilder: (_, i) => _stackTile(_stacks[i]), + ); + } + + Widget _stackTile(ComposeStack stack) { + final selected = _selectedStack?.projectDir == stack.projectDir; + final upKey = 'up_${stack.projectDir}'; + final downKey = 'down_${stack.projectDir}'; + return ListTile( + dense: true, + selected: selected, + selectedTileColor: AppColors.accent.withValues(alpha: 0.08), + title: Text(stack.name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + subtitle: Text('${stack.status} • ${stack.projectDir}', + style: + const TextStyle(fontSize: 11, color: AppColors.textSecondary), + overflow: TextOverflow.ellipsis), + onTap: () => _selectStack(stack), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (_actionLoading[upKey] == true) + const SizedBox( + width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + else + TextButton( + onPressed: () => _stackAction( + stack, upKey, () => widget.service.composeUp(widget.host, stack)), + child: const Text('Up', style: TextStyle(fontSize: 12)), + ), + if (_actionLoading[downKey] == true) + const SizedBox( + width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + else + TextButton( + onPressed: () => _stackAction( + stack, downKey, () => widget.service.composeDown(widget.host, stack)), + child: const Text('Down', style: TextStyle(fontSize: 12)), + ), + ]), + ); + } + + Widget _buildServiceList() { + if (_services.isEmpty) { + return const Center(child: Text('No services found.')); + } + return ListView.separated( + itemCount: _services.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: AppColors.border), + itemBuilder: (_, i) => _serviceTile(_services[i]), + ); + } + + Widget _serviceTile(ComposeService svc) { + final stack = _selectedStack!; + // 'degraded' = some replicas up — still offer Stop, not Start. + final running = svc.status == 'running' || svc.status == 'degraded'; + final startKey = 'svcstart_${stack.projectDir}_${svc.name}'; + final stopKey = 'svcstop_${stack.projectDir}_${svc.name}'; + final isLogTarget = _logService?.name == svc.name; + + return ListTile( + dense: true, + title: Text(svc.name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + subtitle: Text( + '${svc.status} • ${svc.image} • ${svc.replicas} replica${svc.replicas == 1 ? '' : 's'}', + style: + const TextStyle(fontSize: 11, color: AppColors.textSecondary)), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (running && _actionLoading[stopKey] != true) + IconButton( + tooltip: 'Stop service', + icon: const Icon(Icons.stop_circle_outlined, size: 16), + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + onPressed: () => _stackAction( + stack, stopKey, + () => widget.service.stopComposeService(widget.host, stack, svc.name)), + ), + if (!running && _actionLoading[startKey] != true) + IconButton( + tooltip: 'Start service', + icon: const Icon(Icons.play_circle_outline, size: 16), + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + onPressed: () => _stackAction( + stack, startKey, + () => widget.service.startComposeService(widget.host, stack, svc.name)), + ), + IconButton( + tooltip: 'Logs', + icon: const Icon(Icons.article_outlined, size: 16), + color: isLogTarget ? AppColors.accent : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + onPressed: () => + isLogTarget ? _closeLogs() : _openServiceLogs(svc), + ), + ]), + ); + } + + Widget _buildLogPanel() { + return Expanded( + flex: 2, + child: Column(children: [ + Container( + color: AppColors.card, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row(children: [ + const Icon(Icons.article_outlined, + size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Expanded( + child: Text('Logs: ${_logService!.name}', + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + overflow: TextOverflow.ellipsis), + ), + TextButton( + onPressed: () => setState(() => _logLines.clear()), + child: const Text('Clear', style: TextStyle(fontSize: 11)), + ), + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: _closeLogs, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + padding: EdgeInsets.zero, + ), + ]), + ), + Expanded( + child: ListView.builder( + controller: _logScroll, + padding: const EdgeInsets.all(8), + itemCount: _logLines.length, + itemBuilder: (_, i) => Text(_logLines[i], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: AppColors.textPrimary)), + ), + ), + ]), + ); + } +} diff --git a/app/lib/widgets/containers_screen.dart b/app/lib/widgets/containers_screen.dart index ed1aa78f..1ce83bdd 100644 --- a/app/lib/widgets/containers_screen.dart +++ b/app/lib/widgets/containers_screen.dart @@ -5,6 +5,8 @@ import 'package:provider/provider.dart'; import '../models/container_entry.dart'; import '../models/host.dart'; import 'kubernetes_panel.dart'; +import 'docker_panel.dart'; +import 'compose_panel.dart'; import '../providers/session_provider.dart'; import '../services/container_service.dart'; import '../services/ssh_service.dart'; @@ -18,16 +20,14 @@ class ContainersScreen extends StatefulWidget { State createState() => _ContainersScreenState(); } -enum _Tab { docker, kubernetes } +enum _Tab { docker, compose, kubernetes } class _ContainersScreenState extends State { ContainerService? _service; - String? _sessionId; // active session id used as source + String? _sessionId; _Tab _tab = _Tab.docker; RuntimeStatus? _runtimes; - List _containers = []; - bool _loading = false; String? _error; @@ -45,7 +45,6 @@ class _ContainersScreenState extends State { message: 'Open an SSH session first, then come back to browse containers.', ); } - // Default to the active/first session. _sessionId ??= sessions.first.id; final selected = sessions.firstWhere( (s) => s.id == _sessionId, @@ -71,12 +70,15 @@ class _ContainersScreenState extends State { onChanged: (v) => setState(() { _sessionId = v; _runtimes = null; + // Drop the previous session's scan error so it isn't shown + // against the newly-selected session. + _error = null; }), ), ), const SizedBox(width: 12), IconButton( - tooltip: 'Refresh', + tooltip: 'Rescan runtimes', icon: const Icon(Icons.refresh), onPressed: _loading ? null : _refresh, ), @@ -86,6 +88,8 @@ class _ContainersScreenState extends State { Row(children: [ _tabButton(_Tab.docker, 'Docker'), const SizedBox(width: 8), + _tabButton(_Tab.compose, 'Compose'), + const SizedBox(width: 8), _tabButton(_Tab.kubernetes, 'Kubernetes'), ]), const SizedBox(height: 8), @@ -100,11 +104,7 @@ class _ContainersScreenState extends State { return ChoiceChip( label: Text(label), selected: active, - onSelected: (_) => setState(() { - _tab = tab; - _containers = []; - _error = null; - }), + onSelected: (_) => setState(() => _tab = tab), ); } @@ -116,6 +116,14 @@ class _ContainersScreenState extends State { } final runtimes = _runtimes; if (runtimes == null) { + if (_error != null) { + return _CenterHint( + icon: Icons.error_outline, + message: _error!, + actionLabel: 'Retry', + onAction: _refresh, + ); + } return _CenterHint( icon: Icons.search, message: 'Tap refresh to scan for Docker / Kubernetes.', @@ -125,7 +133,7 @@ class _ContainersScreenState extends State { } final avail = _availabilityFor(runtimes); - final runtimeName = _tab == _Tab.docker ? 'docker' : 'kubectl'; + final runtimeName = _tab == _Tab.kubernetes ? 'kubectl' : 'docker'; if (avail == RuntimeAvailability.notInstalled) { return _HintCard( @@ -139,70 +147,40 @@ class _ContainersScreenState extends State { command: ContainerService.permissionHint(runtimeName), ); } - if (_error != null) { - return _CenterHint(icon: Icons.error_outline, message: _error!); - } - if (_tab == _Tab.docker) return _dockerList(); - return KubernetesPanel(host: host, onOpenBrowser: widget.onOpenBrowser); - } - Widget _dockerList() { - if (_containers.isEmpty) { - return const _CenterHint(icon: Icons.inbox, message: 'No running containers.'); + // Runtime is available — the panels load their own data on init. Each is + // remounted (fresh initState) whenever the session changes, because + // switching sessions sets `_runtimes = null`, which unmounts the panel + // until the next scan. + final svc = _ensureService(); + switch (_tab) { + case _Tab.docker: + return DockerPanel(host: host, service: svc); + case _Tab.compose: + return ComposePanel(host: host, service: svc); + case _Tab.kubernetes: + return KubernetesPanel(host: host, onOpenBrowser: widget.onOpenBrowser); } - return ListView.separated( - itemCount: _containers.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (_, i) { - final c = _containers[i]; - return ListTile( - title: Text(c.name), - subtitle: Text('${c.image} • ${c.status}'), - trailing: FilledButton.icon( - icon: const Icon(Icons.terminal, size: 16), - label: const Text('Exec'), - onPressed: () => _execContainer(c), - ), - ); - }, - ); } - // ── Actions ─────────────────────────────────────────── Future _refresh() async { final host = _hostForSelected(); if (host == null) return; - setState(() { - _loading = true; - _error = null; - }); + setState(() { _loading = true; _error = null; }); try { - final svc = _ensureService(); - _runtimes = await svc.detectRuntimes(host); - if (_tab == _Tab.docker && - _availabilityFor(_runtimes!) == RuntimeAvailability.available) { - _containers = await svc.listDockerContainers(host); - } + _runtimes = await _ensureService().detectRuntimes(host); } catch (e) { + // Clear stale runtimes so _body() reaches the error branch instead of + // silently rendering the previous scan's panel. + _runtimes = null; _error = e.toString(); } finally { if (mounted) setState(() => _loading = false); } } - Future _execContainer(ContainerEntry c) async { - if (!mounted) return; - final host = _hostForSelected(); - if (host == null) return; - final sessionProvider = context.read(); - await sessionProvider.connect( - host, - initialCommand: ContainerService.dockerExecCommand(c.id), - ); - } - RuntimeAvailability _availabilityFor(RuntimeStatus r) => - _tab == _Tab.docker ? r.docker : r.kubectl; + _tab == _Tab.kubernetes ? r.kubectl : r.docker; Host? _hostForSelected() { final id = _sessionId; diff --git a/app/lib/widgets/docker_panel.dart b/app/lib/widgets/docker_panel.dart new file mode 100644 index 00000000..40bf4ff4 --- /dev/null +++ b/app/lib/widgets/docker_panel.dart @@ -0,0 +1,345 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/container_entry.dart'; +import '../models/host.dart'; +import '../providers/session_provider.dart'; +import '../services/container_service.dart'; +import '../theme/app_theme.dart'; + +class DockerPanel extends StatefulWidget { + const DockerPanel({super.key, required this.host, required this.service}); + + final Host host; + final ContainerService service; + + @override + State createState() => _DockerPanelState(); +} + +class _DockerPanelState extends State { + List _containers = []; + bool _loading = false; + String? _error; + + // Log panel state + ContainerEntry? _logContainer; + StreamSubscription? _logSub; + final List _logLines = []; + final ScrollController _logScroll = ScrollController(); + bool _autoScroll = true; + + // Per-container action loading + final Map _actionLoading = {}; + + @override + void initState() { + super.initState(); + _logScroll.addListener(_onLogScroll); + _refresh(); + } + + @override + void dispose() { + _logSub?.cancel(); + _logScroll.dispose(); + super.dispose(); + } + + void _onLogScroll() { + final pos = _logScroll.position; + _autoScroll = pos.pixels >= pos.maxScrollExtent - 8; + } + + void _scrollToBottom() { + if (!_autoScroll) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_logScroll.hasClients) { + _logScroll.jumpTo(_logScroll.position.maxScrollExtent); + } + }); + } + + /// [silent] skips the full-screen loading state — used for the refresh that + /// follows a lifecycle action so the list (and any open log panel) isn't torn + /// down and replaced by a spinner on every Stop/Start/Restart. + Future _refresh({bool silent = false}) async { + if (!silent) setState(() { _loading = true; _error = null; }); + try { + final result = await widget.service.listDockerContainers(widget.host); + if (mounted) { + setState(() { + _containers = result; + _error = null; + if (!silent) _loading = false; + // Close log panel if the logged container is no longer in the list. + if (_logContainer != null && + !result.any((c) => c.id == _logContainer!.id)) { + _logSub?.cancel(); + _logSub = null; + _logContainer = null; + _logLines.clear(); + } + }); + } + } catch (e) { + if (mounted) setState(() { _error = e.toString(); if (!silent) _loading = false; }); + } + } + + bool _isRunning(ContainerEntry c) { + final s = c.status.toLowerCase(); + // `Up …` (incl. `Up … (Paused)`) and `Restarting …` are live containers — + // they get Stop/Restart, not Start. + return s.startsWith('up') || s.startsWith('restarting'); + } + + Future _runAction(ContainerEntry c, Future Function() action) async { + setState(() => _actionLoading[c.id] = true); + try { + await action(); + if (!mounted) return; + await _refresh(silent: true); + } catch (e) { + if (mounted) AppSnack.error(context, e.toString()); + } finally { + if (mounted) setState(() => _actionLoading.remove(c.id)); + } + } + + void _openLogs(ContainerEntry c) { + _logSub?.cancel(); + setState(() { + _logContainer = c; + _logLines.clear(); + _autoScroll = true; + }); + _logSub = widget.service + .streamDockerLogs(widget.host, c.id) + .listen((line) { + if (mounted) { + setState(() { + _logLines.add(line); + if (_logLines.length > 2000) { + _logLines.removeRange(0, _logLines.length - 2000); + } + }); + _scrollToBottom(); + } + }, onDone: () { + if (mounted) { + setState(() => _logLines.add('— connection closed —')); + _scrollToBottom(); + } + }, onError: (e) { + if (mounted) { + setState(() => _logLines.add('— error: $e —')); + _scrollToBottom(); + } + }); + } + + void _closeLogs() { + _logSub?.cancel(); + _logSub = null; + setState(() { + _logContainer = null; + _logLines.clear(); + }); + } + + Future _execContainer(ContainerEntry c) async { + if (!mounted) return; + final sessionProvider = context.read(); + try { + await sessionProvider.connect( + widget.host, + initialCommand: ContainerService.dockerExecCommand(c.id), + ); + } catch (e) { + if (mounted) AppSnack.error(context, e.toString()); + } + } + + @override + Widget build(BuildContext context) { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.error_outline, size: 36, color: AppColors.textTertiary), + const SizedBox(height: 8), + Text(_error!, textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton(onPressed: _refresh, child: const Text('Retry')), + ]), + ); + } + if (_containers.isEmpty) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.inbox, size: 36, color: AppColors.textTertiary), + const SizedBox(height: 8), + const Text('No containers found.'), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Refresh'), + onPressed: _refresh), + ]), + ); + } + + final hasLogs = _logContainer != null; + return Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 4), + child: Row( + children: [ + Text('${_containers.length} container${_containers.length == 1 ? '' : 's'}', + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 12)), + const Spacer(), + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh, size: 16), + onPressed: _refresh, + ), + ], + ), + ), + // Container list + Expanded( + flex: hasLogs ? 1 : 2, + child: ListView.separated( + itemCount: _containers.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: AppColors.border), + itemBuilder: (_, i) => _containerTile(_containers[i]), + ), + ), + // Log panel + if (hasLogs) ...[ + const Divider(height: 1, color: AppColors.border), + _logPanel(), + ], + ], + ); + } + + Widget _containerTile(ContainerEntry c) { + final running = _isRunning(c); + final loading = _actionLoading[c.id] == true; + final isLogTarget = _logContainer?.id == c.id; + + return Container( + decoration: isLogTarget + ? BoxDecoration( + border: Border( + left: BorderSide(color: AppColors.accent, width: 3), + ), + ) + : null, + child: ListTile( + dense: true, + title: Text(c.name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + subtitle: Text('${c.image} • ${c.status}', + style: + const TextStyle(fontSize: 11, color: AppColors.textSecondary)), + trailing: loading + ? const SizedBox( + width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : _actionButtons(c, running), + ), + ); + } + + Widget _actionButtons(ContainerEntry c, bool running) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + if (running) + _iconBtn(Icons.stop_circle_outlined, 'Stop', + () => _runAction(c, () => widget.service.stopContainer(widget.host, c.id))), + if (running) + _iconBtn(Icons.replay, 'Restart', + () => _runAction(c, () => widget.service.restartContainer(widget.host, c.id))), + if (!running) + _iconBtn(Icons.play_circle_outline, 'Start', + () => _runAction(c, () => widget.service.startContainer(widget.host, c.id))), + _iconBtn(Icons.terminal, 'Exec', () => _execContainer(c)), + if (running) + _iconBtn( + Icons.article_outlined, + 'Logs', + () => _logContainer?.id == c.id ? _closeLogs() : _openLogs(c), + active: _logContainer?.id == c.id, + ), + ]); + } + + Widget _iconBtn(IconData icon, String tooltip, VoidCallback onTap, + {bool active = false}) { + return IconButton( + tooltip: tooltip, + icon: Icon(icon, size: 16), + color: active ? AppColors.accent : null, + onPressed: onTap, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + padding: EdgeInsets.zero, + ); + } + + Widget _logPanel() { + return Expanded( + flex: 2, + child: Column( + children: [ + // Log panel header + Container( + color: AppColors.card, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row(children: [ + const Icon(Icons.article_outlined, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Expanded( + child: Text('Logs: ${_logContainer!.name}', + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + overflow: TextOverflow.ellipsis), + ), + TextButton( + onPressed: () => setState(() => _logLines.clear()), + child: const Text('Clear', style: TextStyle(fontSize: 11)), + ), + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: _closeLogs, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + padding: EdgeInsets.zero, + ), + ]), + ), + // Log lines + Expanded( + child: ListView.builder( + controller: _logScroll, + padding: const EdgeInsets.all(8), + itemCount: _logLines.length, + itemBuilder: (_, i) => Text( + _logLines[i], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: AppColors.textPrimary), + ), + ), + ), + ], + ), + ); + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c6ffd6d8..5748b3eb 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: yourssh description: YourSSH - A professional SSH client for macOS, Windows, and Linux with advanced features like SFTP, port forwarding, and a built-in code editor. publish_to: 'none' -version: 0.1.37+1 +version: 0.1.38+1 environment: sdk: ^3.12.0 diff --git a/app/test/services/container_service_compose_test.dart b/app/test/services/container_service_compose_test.dart new file mode 100644 index 00000000..afa3d3d5 --- /dev/null +++ b/app/test/services/container_service_compose_test.dart @@ -0,0 +1,385 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/container_entry.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/container_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; + +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + ({String stdout, String stderr, int exitCode}) Function(String cmd)? execStub; + Stream Function(String cmd)? streamStub; + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String cmd, {String? auditSource}) async => + execStub?.call(cmd) ?? (stdout: '', stderr: '', exitCode: 0); + + @override + Stream execStream(Host host, String cmd, {String? auditSource}) => + streamStub?.call(cmd) ?? const Stream.empty(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + final host = Host(label: 'h', host: 'srv', port: 22, username: 'u'); + + // ── parseComposeLs ───────────────────────────────────── + + group('parseComposeLs', () { + test('parses valid JSON array', () { + const json = '[{"Name":"proj","Status":"running(2)","ConfigFiles":"/opt/proj/compose.yml"}]'; + final stacks = ContainerService.parseComposeLs(json); + expect(stacks.length, 1); + expect(stacks[0].name, 'proj'); + expect(stacks[0].projectDir, '/opt/proj'); + expect(stacks[0].status, 'running(2)'); + }); + + test('returns empty list for empty array', () { + expect(ContainerService.parseComposeLs('[]'), isEmpty); + }); + + test('returns empty list for malformed JSON', () { + expect(ContainerService.parseComposeLs('not-json'), isEmpty); + }); + + test('returns empty list for empty string', () { + expect(ContainerService.parseComposeLs(''), isEmpty); + }); + + test('uses dirname of first ConfigFiles entry as projectDir', () { + const json = '[{"Name":"x","Status":"exited(1)","ConfigFiles":"/srv/app/docker-compose.yml,/srv/app/override.yml"}]'; + final stacks = ContainerService.parseComposeLs(json); + expect(stacks[0].projectDir, '/srv/app'); + }); + + test('a slash-less ConfigFiles entry is skipped, not fatal to the list', () { + const json = + '[{"Name":"bad","Status":"x","ConfigFiles":"compose.yml"},' + '{"Name":"good","Status":"running(1)","ConfigFiles":"/opt/good/compose.yml"}]'; + final stacks = ContainerService.parseComposeLs(json); + expect(stacks.length, 1); + expect(stacks[0].name, 'good'); + expect(stacks[0].projectDir, '/opt/good'); + }); + }); + + // ── parseComposeFindOutput ────────────────────────────── + + group('parseComposeFindOutput', () { + test('parses find output into stacks', () { + const output = '/home/user/myapp/docker-compose.yml\n/opt/blog/compose.yml\n'; + final stacks = ContainerService.parseComposeFindOutput(output); + expect(stacks.length, 2); + expect(stacks[0].projectDir, '/home/user/myapp'); + expect(stacks[0].name, 'myapp'); + expect(stacks[0].status, 'unknown'); + expect(stacks[1].projectDir, '/opt/blog'); + }); + + test('deduplicates by projectDir', () { + const output = + '/opt/proj/docker-compose.yml\n/opt/proj/docker-compose.override.yml\n'; + final stacks = ContainerService.parseComposeFindOutput(output); + expect(stacks.length, 1); + }); + + test('returns empty list for empty output', () { + expect(ContainerService.parseComposeFindOutput(''), isEmpty); + }); + + test('skips root-level compose file with empty projectDir', () { + // /compose.yml → lastSlash=0 → projectDir='' → must be filtered out + const output = '/compose.yml\n/opt/app/compose.yml\n'; + final stacks = ContainerService.parseComposeFindOutput(output); + expect(stacks.length, 1); + expect(stacks[0].projectDir, '/opt/app'); + }); + }); + + // ── parseComposePs ────────────────────────────────────── + + group('parseComposePs', () { + test('parses JSONL output (one JSON object per line)', () { + const output = + '{"Name":"myapp-web-1","Service":"web","State":"running","Image":"nginx:latest"}\n' + '{"Name":"myapp-db-1","Service":"db","State":"running","Image":"postgres:15"}\n'; + final services = ContainerService.parseComposePs(output); + expect(services.length, 2); + expect(services[0].name, 'web'); + expect(services[0].status, 'running'); + expect(services[0].image, 'nginx:latest'); + }); + + test('parses JSON array output', () { + const output = + '[{"Name":"myapp-web-1","Service":"web","State":"running","Image":"nginx:latest"}]'; + final services = ContainerService.parseComposePs(output); + expect(services.length, 1); + expect(services[0].name, 'web'); + }); + + test('aggregates replicas for same service name', () { + const output = + '{"Name":"app-worker-1","Service":"worker","State":"running","Image":"myimg"}\n' + '{"Name":"app-worker-2","Service":"worker","State":"running","Image":"myimg"}\n'; + final services = ContainerService.parseComposePs(output); + expect(services.length, 1); + expect(services[0].replicas, 2); + }); + + test('returns empty list for empty output', () { + expect(ContainerService.parseComposePs(''), isEmpty); + }); + + test('returns empty list for malformed JSON', () { + expect(ContainerService.parseComposePs('garbage'), isEmpty); + }); + + test('aggregates all-running replicas as running', () { + const output = + '{"Name":"app-worker-1","Service":"worker","State":"running","Image":"myimg"}\n' + '{"Name":"app-worker-2","Service":"worker","State":"running","Image":"myimg"}\n'; + final services = ContainerService.parseComposePs(output); + expect(services.length, 1); + expect(services[0].status, 'running'); + expect(services[0].replicas, 2); + }); + + test('marks degraded service when some replicas running and some exited', () { + const output = + '{"Name":"app-web-1","Service":"web","State":"running","Image":"nginx"}\n' + '{"Name":"app-web-2","Service":"web","State":"exited","Image":"nginx"}\n'; + final services = ContainerService.parseComposePs(output); + expect(services.length, 1); + expect(services[0].status, 'degraded'); + expect(services[0].replicas, 2); + }); + }); + + // ── listComposeServices ──────────────────────────────── + + group('listComposeServices', () { + test('passes correct command', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '[]', stderr: '', exitCode: 0); }; + await ContainerService(fake).listComposeServices( + host, ComposeStack(name: 'a', projectDir: '/opt/app', status: 'x')); + expect(cmd, "cd '/opt/app' && docker compose ps --format json 2>/dev/null"); + }); + + test('throws on non-zero exit', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'boom', exitCode: 1); + await expectLater( + ContainerService(fake).listComposeServices( + host, ComposeStack(name: 'a', projectDir: '/p', status: 'x')), + throwsException); + }); + }); + + // ── discoverComposeStacks dedup ───────────────────────── + + group('discoverComposeStacks', () { + test('deduplicates by projectDir: ls result takes precedence over find', () async { + final fake = _FakeSshService(); + // First cmd: docker compose ls; second: find + fake.execStub = (cmd) { + if (cmd.contains('docker compose ls')) { + return ( + stdout: '[{"Name":"myapp","Status":"running(1)","ConfigFiles":"/opt/myapp/compose.yml"}]', + stderr: '', + exitCode: 0, + ); + } + // find output — same projectDir + return (stdout: '/opt/myapp/compose.yml\n', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + final stacks = await svc.discoverComposeStacks(host); + // Both sources found /opt/myapp — should appear once only, with status from ls + final myapp = stacks.where((s) => s.projectDir == '/opt/myapp').toList(); + expect(myapp.length, 1); + expect(myapp[0].status, 'running(1)'); + }); + }); + + // ── compose actions ───────────────────────────────────── + + group('composeUp', () { + test('runs docker compose up -d in projectDir', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + final stack = ComposeStack(name: 'app', projectDir: '/opt/app', status: 'exited'); + await ContainerService(fake).composeUp(host, stack); + expect(cmd, "cd '/opt/app' && docker compose up -d"); + }); + + test('throws on non-zero exit', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'pull error', exitCode: 1); + await expectLater( + ContainerService(fake).composeUp(host, + ComposeStack(name: 'a', projectDir: '/p', status: 'x')), + throwsException); + }); + }); + + group('composeDown', () { + test('runs docker compose down in projectDir', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).composeDown( + host, ComposeStack(name: 'a', projectDir: '/srv/a', status: 'x')); + expect(cmd, "cd '/srv/a' && docker compose down"); + }); + }); + + group('startComposeService / stopComposeService', () { + test('start passes service name', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).startComposeService( + host, ComposeStack(name: 'app', projectDir: '/p', status: 'x'), 'web'); + expect(cmd, "cd '/p' && docker compose start 'web'"); + }); + + test('stop passes service name', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).stopComposeService( + host, ComposeStack(name: 'app', projectDir: '/p', status: 'x'), 'web'); + expect(cmd, "cd '/p' && docker compose stop 'web'"); + }); + }); + + group('streamComposeServiceLogs', () { + test('passes correct command with tail and service name', () { + final fake = _FakeSshService(); + String? cmd; + fake.streamStub = (c) { cmd = c; return const Stream.empty(); }; + ContainerService(fake).streamComposeServiceLogs( + host, ComposeStack(name: 'app', projectDir: '/opt/app', status: 'x'), 'web', + tail: 50); + expect(cmd, "cd '/opt/app' && docker compose logs -f --tail=50 'web' 2>&1"); + }); + }); + + // ── shell quoting (_shq) ──────────────────────────────── + + group('shell quoting via composeUp', () { + test('quote-free projectDir produces unchanged single-quoted command', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).composeUp( + host, ComposeStack(name: 'app', projectDir: '/opt/app', status: 'x')); + expect(cmd, "cd '/opt/app' && docker compose up -d"); + }); + + test('projectDir with single quote is properly escaped', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).composeUp( + host, ComposeStack(name: "a'b", projectDir: "/opt/a'b", status: 'x')); + expect(cmd, r"cd '/opt/a'\''b' && docker compose up -d"); + }); + }); + + // ── validateComposeFile ───────────────────────────────── + + group('validateComposeFile', () { + test('passes correct command and returns service names', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { + cmd = c; + return (stdout: 'web\ndb\n', stderr: '', exitCode: 0); + }; + final names = await ContainerService(fake) + .validateComposeFile(host, '/opt/app/compose.yml'); + expect(cmd, "docker compose -f '/opt/app/compose.yml' config --services 2>&1"); + expect(names, ['web', 'db']); + }); + + test('throws on non-zero exit', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'no compose file', exitCode: 1); + await expectLater( + ContainerService(fake).validateComposeFile(host, '/bad/path.yml'), + throwsException, + ); + }); + }); + + // ── discoverComposeStacks: find exit code (#1) ────────── + + group('discoverComposeStacks — find exit code', () { + test('keeps find matches even when find exits non-zero (missing search root)', + () async { + final fake = _FakeSshService(); + fake.execStub = (cmd) { + if (cmd.contains('docker compose ls')) { + return (stdout: '', stderr: '', exitCode: 0); + } + // `find ~ /opt /srv /home` emitted a real match to stdout but exited + // non-zero because /srv does not exist on this host. + return ( + stdout: '/home/me/app/docker-compose.yml\n', + stderr: "find: '/srv': No such file or directory", + exitCode: 1, + ); + }; + final stacks = await ContainerService(fake).discoverComposeStacks(host); + expect(stacks.map((s) => s.projectDir), contains('/home/me/app')); + }); + }); + + // ── composeStackFromPath (#8) ─────────────────────────── + + group('composeStackFromPath', () { + test('derives name and dir from a normal path', () { + final s = + ContainerService.composeStackFromPath('/opt/app/docker-compose.yml'); + expect(s, isNotNull); + expect(s!.projectDir, '/opt/app'); + expect(s.name, 'app'); + expect(s.status, 'unknown'); + }); + + test('root-level compose file yields projectDir "/" not empty', () { + final s = ContainerService.composeStackFromPath('/compose.yml'); + expect(s, isNotNull); + expect(s!.projectDir, '/'); + expect(s.name, isNotEmpty); + }); + + test('returns null for a non-absolute path', () { + expect( + ContainerService.composeStackFromPath('relative/compose.yml'), isNull); + }); + }); + + // ── parseComposePs empty-state fallback (#9) ──────────── + + group('parseComposePs — empty state', () { + test('falls back to "unknown" when State is empty', () { + const out = + '[{"Name":"x-web-1","Service":"web","State":"","Image":"nginx"}]'; + final svcs = ContainerService.parseComposePs(out); + expect(svcs.single.status, 'unknown'); + }); + }); +} diff --git a/app/test/services/container_service_docker_test.dart b/app/test/services/container_service_docker_test.dart new file mode 100644 index 00000000..18cc9c0f --- /dev/null +++ b/app/test/services/container_service_docker_test.dart @@ -0,0 +1,148 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/container_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Minimal SshService stub: override only exec and execStream. +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + ({String stdout, String stderr, int exitCode}) Function(String cmd)? execStub; + Stream Function(String cmd)? streamStub; + String? lastAuditSource; + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String cmd, {String? auditSource}) async { + lastAuditSource = auditSource; + return execStub?.call(cmd) ?? (stdout: '', stderr: '', exitCode: 0); + } + + @override + Stream execStream(Host host, String cmd, {String? auditSource}) { + lastAuditSource = auditSource; + return streamStub?.call(cmd) ?? const Stream.empty(); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + final host = Host(label: 'h', host: 'srv', port: 22, username: 'u'); + + group('listDockerContainers', () { + test('issues docker ps -a with the expected format string', () async { + final fake = _FakeSshService(); + String? capturedCmd; + fake.execStub = (cmd) { + capturedCmd = cmd; + return (stdout: '', stderr: '', exitCode: 0); + }; + await ContainerService(fake).listDockerContainers(host); + expect(capturedCmd, + "docker ps -a --format '{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}'"); + }); + }); + + group('streamDockerLogs', () { + test('passes correct command with tail flag and stderr merge', () { + final fake = _FakeSshService(); + String? capturedCmd; + fake.streamStub = (cmd) { + capturedCmd = cmd; + return const Stream.empty(); + }; + final svc = ContainerService(fake); + svc.streamDockerLogs(host, 'abc123', tail: 50); + expect(capturedCmd, "docker logs -f --tail=50 'abc123' 2>&1"); + }); + + test('default tail is 100', () { + final fake = _FakeSshService(); + String? capturedCmd; + fake.streamStub = (cmd) { capturedCmd = cmd; return const Stream.empty(); }; + ContainerService(fake).streamDockerLogs(host, 'xyz'); + expect(capturedCmd, "docker logs -f --tail=100 'xyz' 2>&1"); + }); + }); + + group('stopContainer', () { + test('does not throw on exit 0', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + await expectLater(ContainerService(fake).stopContainer(host, 'c1'), completes); + }); + + test('throws Exception with stderr on exit 1', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'No such container: c1', exitCode: 1); + await expectLater( + ContainerService(fake).stopContainer(host, 'c1'), + throwsA(isA().having( + (e) => e.toString(), 'message', contains('No such container')))); + }); + + test('passes correct command', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).stopContainer(host, 'abc'); + expect(cmd, "docker stop 'abc'"); + }); + }); + + group('startContainer', () { + test('does not throw on exit 0', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + await expectLater(ContainerService(fake).startContainer(host, 'c1'), completes); + }); + + test('throws on exit 1', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'Error response', exitCode: 1); + await expectLater(ContainerService(fake).startContainer(host, 'c1'), + throwsException); + }); + + test('passes correct command', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).startContainer(host, 'abc'); + expect(cmd, "docker start 'abc'"); + }); + }); + + group('restartContainer', () { + test('passes correct command', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).restartContainer(host, 'abc'); + expect(cmd, "docker restart 'abc'"); + }); + + test('throws on failure', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'oops', exitCode: 1); + await expectLater(ContainerService(fake).restartContainer(host, 'c1'), + throwsException); + }); + }); + + group('auditSource', () { + test('container calls carry auditSource devops', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + final svc = ContainerService(fake); + await svc.stopContainer(host, 'c1'); + expect(fake.lastAuditSource, 'devops'); + svc.streamDockerLogs(host, 'c1'); + expect(fake.lastAuditSource, 'devops'); + }); + }); +} diff --git a/app/test/services/container_service_test.dart b/app/test/services/container_service_test.dart index 7228d789..ab37d0bf 100644 --- a/app/test/services/container_service_test.dart +++ b/app/test/services/container_service_test.dart @@ -113,11 +113,15 @@ void main() { }); group('exec commands', () { - test('docker exec uses bash->sh fallback', () { + test('docker exec uses bash->sh fallback and shell-quotes the id', () { final cmd = ContainerService.dockerExecCommand('abc123'); - expect(cmd, contains('docker exec -it abc123')); + expect(cmd, contains("docker exec -it 'abc123'")); expect(cmd, contains('exec bash || exec sh')); }); + test('docker exec quotes an id containing a space', () { + final cmd = ContainerService.dockerExecCommand('my container'); + expect(cmd, contains("docker exec -it 'my container'")); + }); test('kubectl exec includes namespace and container', () { final cmd = ContainerService.kubectlExecCommand('web-0', 'prod', 'app'); expect(cmd, contains('kubectl exec -it web-0')); diff --git a/app/test/widgets/compose_panel_test.dart b/app/test/widgets/compose_panel_test.dart new file mode 100644 index 00000000..5665a730 --- /dev/null +++ b/app/test/widgets/compose_panel_test.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/container_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/compose_panel.dart'; + +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + ({String stdout, String stderr, int exitCode}) Function(String cmd)? execStub; + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String cmd, {String? auditSource}) async => + execStub?.call(cmd) ?? (stdout: '', stderr: '', exitCode: 0); + + @override + Stream execStream(Host host, String cmd, {String? auditSource}) => + const Stream.empty(); +} + +Widget _wrap(Widget child) => + MaterialApp(home: Scaffold(body: child)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + final host = Host(label: 'h', host: 'srv', port: 22, username: 'u'); + + testWidgets('shows No Compose stacks found when discovery returns empty', (tester) async { + final fake = _FakeSshService(); + // Both docker compose ls and find return empty + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + expect(find.text('No Compose stacks found.'), findsOneWidget); + }); + + testWidgets('shows stack name when discovery returns a result', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (cmd) { + if (cmd.contains('docker compose ls')) { + return ( + stdout: '[{"Name":"myapp","Status":"running(2)","ConfigFiles":"/opt/myapp/compose.yml"}]', + stderr: '', + exitCode: 0, + ); + } + return (stdout: '', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + expect(find.text('myapp'), findsOneWidget); + expect(find.text('Up'), findsOneWidget); + expect(find.text('Down'), findsOneWidget); + }); + + testWidgets('shows service tile with Stop control after tapping a running stack', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (cmd) { + if (cmd.contains('docker compose ls')) { + return ( + stdout: '[{"Name":"myapp","Status":"running(1)","ConfigFiles":"/opt/myapp/compose.yml"}]', + stderr: '', + exitCode: 0, + ); + } + if (cmd.contains('docker compose ps --format json')) { + return ( + stdout: '[{"Name":"myapp-web-1","Service":"web","State":"running","Image":"nginx:latest"}]', + stderr: '', + exitCode: 0, + ); + } + return (stdout: '', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + // Stack should be visible + expect(find.text('myapp'), findsOneWidget); + + // Tap the stack tile to expand services + await tester.tap(find.text('myapp')); + await tester.pumpAndSettle(); + + // Service row should render + expect(find.text('web'), findsOneWidget); + // Running service should show Stop control + expect(find.byTooltip('Stop service'), findsOneWidget); + }); + + testWidgets('closes log panel when stack selection changes', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (cmd) { + if (cmd.contains('docker compose ls')) { + return ( + stdout: + '[{"Name":"alpha","Status":"running(1)","ConfigFiles":"/opt/alpha/compose.yml"},' + '{"Name":"beta","Status":"running(1)","ConfigFiles":"/opt/beta/compose.yml"}]', + stderr: '', + exitCode: 0, + ); + } + if (cmd.contains('docker compose ps --format json')) { + return ( + stdout: '[{"Name":"alpha-web-1","Service":"web","State":"running","Image":"nginx:latest"}]', + stderr: '', + exitCode: 0, + ); + } + return (stdout: '', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + // Select the first stack to load its services + await tester.tap(find.text('alpha')); + await tester.pumpAndSettle(); + + // Open the log panel for the service + await tester.tap(find.byTooltip('Logs').first); + await tester.pump(); + expect(find.textContaining('Logs:'), findsOneWidget); + + // Now select a different stack — log panel must close + await tester.tap(find.text('beta')); + await tester.pumpAndSettle(); + expect(find.textContaining('Logs:'), findsNothing); + }); + + testWidgets('shows + button for manual path input', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + await tester.tap(find.byTooltip('Add path manually')); + await tester.pump(); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Add'), findsOneWidget); + }); + + testWidgets('entering a non-absolute manual path shows error snackbar and does not add a stack', (tester) async { + final fake = _FakeSshService(); + bool execCalled = false; + fake.execStub = (cmd) { + // validateComposeFile should NOT be called for a relative path. + if (cmd.contains('config --services')) execCalled = true; + return (stdout: '', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + // Open manual input + await tester.tap(find.byTooltip('Add path manually')); + await tester.pump(); + + // Enter a relative (non-absolute) path + await tester.enterText(find.byType(TextField), 'relative/path/compose.yml'); + await tester.tap(find.text('Add')); + await tester.pump(); + + // An error snackbar should appear + expect(find.byType(SnackBar), findsOneWidget); + // validateComposeFile was never called (no SSH exec for config --services) + expect(execCalled, isFalse); + // No stack was added + expect(find.text('relative'), findsNothing); + }); + + testWidgets('manually-added stack survives a refresh (not wiped by discovery)', + (tester) async { + final fake = _FakeSshService(); + fake.execStub = (cmd) { + // Discovery finds nothing (the path lives outside the scanned roots); + // only validateComposeFile succeeds. + if (cmd.contains('config --services')) { + return (stdout: 'web\n', stderr: '', exitCode: 0); + } + return (stdout: '', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + // Add a manual path outside the discovery roots. + await tester.tap(find.byTooltip('Add path manually')); + await tester.pump(); + await tester.enterText( + find.byType(TextField), '/data/app/docker-compose.yml'); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + expect(find.text('app'), findsOneWidget); + + // Refresh — discovery still returns nothing; the manual stack must remain. + await tester.tap(find.byTooltip('Refresh')); + await tester.pumpAndSettle(); + expect(find.text('app'), findsOneWidget); + }); + + testWidgets('degraded service shows Stop control (treated as running)', + (tester) async { + final fake = _FakeSshService(); + fake.execStub = (cmd) { + if (cmd.contains('docker compose ls')) { + return ( + stdout: + '[{"Name":"myapp","Status":"running(2)","ConfigFiles":"/opt/myapp/compose.yml"}]', + stderr: '', + exitCode: 0, + ); + } + if (cmd.contains('docker compose ps --format json')) { + // 2-replica service, one running + one exited → 'degraded'. + return ( + stdout: + '{"Name":"myapp-web-1","Service":"web","State":"running","Image":"nginx"}\n' + '{"Name":"myapp-web-2","Service":"web","State":"exited","Image":"nginx"}\n', + stderr: '', + exitCode: 0, + ); + } + return (stdout: '', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + await tester.tap(find.text('myapp')); + await tester.pumpAndSettle(); + + expect(find.text('web'), findsOneWidget); + expect(find.byTooltip('Stop service'), findsOneWidget); + expect(find.byTooltip('Start service'), findsNothing); + }); +} diff --git a/app/test/widgets/docker_panel_test.dart b/app/test/widgets/docker_panel_test.dart new file mode 100644 index 00000000..a219a666 --- /dev/null +++ b/app/test/widgets/docker_panel_test.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/container_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/docker_panel.dart'; + +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + final List cmds = []; + ({String stdout, String stderr, int exitCode}) Function(String cmd)? execStub; + + /// When set, exec awaits this gate before returning for `docker ps` calls + /// — used to hold the post-action refresh in-flight and observe the UI. + Completer? psGate; + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String cmd, {String? auditSource}) async { + cmds.add(cmd); + if (psGate != null && cmd.contains('docker ps')) await psGate!.future; + return execStub?.call(cmd) ?? (stdout: '', stderr: '', exitCode: 0); + } + + @override + Stream execStream(Host host, String cmd, {String? auditSource}) => + const Stream.empty(); +} + +// DockerPanel reaches into SessionProvider only inside the Exec button +// callback (context.read()); these tests never tap Exec, so +// no provider ancestor is needed — a bare MaterialApp suffices. +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + final host = Host(label: 'h', host: 'srv', port: 22, username: 'u'); + + testWidgets('shows running containers with Stop and Logs buttons', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => ( + stdout: 'abc123|web|nginx:latest|Up 2 hours', + stderr: '', + exitCode: 0, + ); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + expect(find.text('web'), findsOneWidget); + expect(find.byTooltip('Stop'), findsOneWidget); + expect(find.byTooltip('Logs'), findsOneWidget); + expect(find.byTooltip('Start'), findsNothing); + }); + + testWidgets('shows Start button for stopped container', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => ( + stdout: 'abc123|worker|myimage:1.0|Exited (0) 5 minutes ago', + stderr: '', + exitCode: 0, + ); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + expect(find.byTooltip('Start'), findsOneWidget); + expect(find.byTooltip('Stop'), findsNothing); + }); + + testWidgets('shows No containers found for empty list', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + expect(find.text('No containers found.'), findsOneWidget); + }); + + testWidgets('tapping Logs opens log panel with container name header', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => ( + stdout: 'abc123|web|nginx:latest|Up 2 hours', + stderr: '', + exitCode: 0, + ); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + await tester.tap(find.byTooltip('Logs')); + await tester.pump(); + + expect(find.text('Logs: web'), findsOneWidget); + }); + + testWidgets('restarting container shows Stop/Restart, not Start', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => ( + stdout: 'abc123|web|nginx:latest|Restarting (1) 2 seconds ago', + stderr: '', + exitCode: 0, + ); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + expect(find.byTooltip('Stop'), findsOneWidget); + expect(find.byTooltip('Restart'), findsOneWidget); + expect(find.byTooltip('Start'), findsNothing); + }); + + testWidgets('tapping Stop runs docker stop and re-lists', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => ( + stdout: 'abc123|web|nginx:latest|Up 2 hours', + stderr: '', + exitCode: 0, + ); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + await tester.tap(find.byTooltip('Stop')); + await tester.pumpAndSettle(); + + expect(fake.cmds.any((c) => c.contains('docker stop')), isTrue); + // One ps at init + one after the action's refresh. + expect( + fake.cmds.where((c) => c.contains('docker ps')).length, + greaterThanOrEqualTo(2)); + }); + + testWidgets('post-action refresh keeps the list visible (no full-screen spinner)', + (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => ( + stdout: 'abc123|web|nginx:latest|Up 2 hours', + stderr: '', + exitCode: 0, + ); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + expect(find.text('web'), findsOneWidget); + + // Hold the post-action `docker ps` refresh in-flight. + fake.psGate = Completer(); + await tester.tap(find.byTooltip('Stop')); + await tester.pump(); // action done; refresh now awaiting the gate + + // The list must remain — the action-scoped refresh must NOT replace the + // whole panel with a full-screen spinner. + expect(find.text('web'), findsOneWidget); + + fake.psGate!.complete(); + await tester.pumpAndSettle(); + expect(find.text('web'), findsOneWidget); + }); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 63128c9d..1b0ccaee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,13 +1,15 @@ # YourSSH — Roadmap > Direction: **infra workstation for DevOps/SRE managing 10–100+ hosts**, not just an SSH client. -> Current version: 0.1.37 · updated: 2026-06-18 +> Current version: 0.1.38 · updated: 2026-06-23 This document lists proposed features ordered by priority. Each item can be broken out into its own spec (`docs/superpowers/specs/`) when ready for implementation. Already shipped (not repeated in roadmap): multi-tab terminal, split view, broadcast, recording (asciicast), snippet, SFTP dual-panel, port forwarding, jump host, Supabase sync + P2P LAN, AI chat sidebar with tool calling, plugin system (DevOps / WebTools / Snippets), Cloudflare tunnel, MCP gateway, mail catcher, code editor (Monaco), customizable hotkeys, TOFU known-hosts, **Command Palette (Cmd/Ctrl+K)** — fuzzy search hosts / nav / snippets / actions, **Workspace persistence** — auto-reconnect tabs + layout on relaunch, **Search-in-scrollback (Cmd/Ctrl+F)** — regex, highlights, prev/next navigation, **Script Engine plugin system** — disk-based JS plugins via QuickJS FFI, HookBus (terminal.output / terminal.input / session events), SSH/SFTP/Storage/UI bridges, hot-reload file watcher, PermissionGuard + circuit breaker, consent dialog, manager screen + console log viewer, **Import** — paste SSH config / JSON / CSV with per-host include toggles (`parseSshConfig` in `import_panel.dart`), **Host tagging** — comma-separated tags on the `Host` model, editable in host detail and searchable from the dashboard, **Smart filter + multi-dimensional query (0.1.10)** — `HostQuery` parser with `key:value` faceted AND/OR semantics, toggleable facet chips on hosts dashboard, tag-based search, **Terminal sharing / multiplayer (0.1.13)** — share a live SSH session via Supabase Realtime; guests join with a session code, watch or interact in real time; `ShareSessionService`, `ShareProvider`, `ShareEvent`, split-view watch banner, **Advanced tab management** — rename, color tag, pin, drag reorder; all tab metadata persists per host, **Connection health badge (0.1.17)** — live latency-driven dot per session tab (green/amber/red/grey + pulse), hover tooltip with uptime / last-ping / reconnect count; `HealthMonitorService` pings the live channel (`SSHClient.ping`) as sole pinger, 5s timeout surfaces half-open silent drops, **Shell integration (0.1.18)** — injected bash/zsh prompt-hooks emit OSC 7 + OSC 133 captured via xterm `onPrivateOSC`; cwd on the session tab, per-command status gutter (green/red/grey), jump-to-prompt (`Cmd/Ctrl+↑/↓`), and cwd-aware path autocomplete in the input bar; auto-on with per-host + global opt-out, **In-app updates (0.1.19)** — checks GitHub `releases/latest` on launch (24h debounce) + manual check in Settings; since 0.1.29 also re-checks while the app stays running (6h periodic timer + window-focus check, same 24h debounce) so the notification bell picks up new releases without a restart; dismissible update banner + Settings Updates section; downloads the correct OS/arch artifact and hands off to the OS installer (assisted flow, unsigned-app friendly: macOS strips quarantine + opens the DMG, Windows runs the installer, Linux opens the package); falls back to the Releases page when no artifact matches (e.g. Intel Mac); `UpdateService` + `UpdateProvider` + `UpdateBanner`, **Sudo SFTP (0.1.20)** — per-host SFTP mode running the entire SFTP session as root through `sudo` over an exec channel (WinSCP-style); distro auto-detection, `NOPASSWD` guidance, root badge on elevated panels, **External edit + Open with… (0.1.20–0.1.21)** — open remote files with the OS default app or any installed app (hover submenu; per-OS discovery via `NSWorkspace` / XDG `.desktop` / Windows registry); the local copy is watched and auto-uploaded on every save; plain-text editor fallback where Monaco is unavailable, **SFTP View mode (0.1.21)** — read-only preview separate from Edit, **Invisible shell-integration injection (0.1.21)** — bracketed-paste readiness detection + `read -rs` two-phase handshake delivers the OSC hook installer without ever echoing into the terminal or recordings, **Terminal snippets panel (0.1.21)** — collapsible right-side panel in the terminal workspace to browse/search/copy/run snippets against the active pane, backed by the new plugin `sendInput` API, **Unified terminal tabs (0.1.24)** — local shell sessions are first-class tabs in the global top bar (`TerminalSession` model), split into panes alongside SSH, recordable to asciicast, **SFTP two-panel with switchable sources (0.1.24)** — per-panel Local/host source chip, unified headers (filter + actions), clickable breadcrumbs (#41), workspace kept alive across tab switches (#42), **Terminal copy/paste UX (0.1.24)** — selection-gated Ctrl+C copy (SIGINT preserved), Ctrl(+Shift)+V paste, right-click Copy/Paste/Select All menu, middle-click paste (#43), readable semi-transparent selection colors in all themes (#40), **Notification bell (0.1.25)** — bell in the top tab bar with unread badge + anchored popover; update-available items carry a one-click Update button, unexpected session drops (no pending auto-reconnect) are collected per session; mark-read on open, per-item dismiss, clear all; in-memory `NotificationCenterProvider` (deduped, capped at 50), **Terminal appearance side panel** — tune icon in the terminal toolbar opens a right-side panel (mutually exclusive with the snippets panel via the `SidePanel` enum; shared `WorkspaceSidePanel` frame) to change color theme, font size (live preview while dragging, persisted once on release), and font family without leaving the workspace; controls shared with Settings → Terminal via `TerminalAppearanceControls`, **Terminal theme catalog 35 → 44** — added Kanagawa Dragon/Lotus, Tokyo Night Day, Nord Light, Light Owl, Flexoki Dark/Light, Aura, and Cyberpunk from their authors' published palettes, grouped next to their families in the picker, with visibility-tuned cursor/selection/search colors on the light variants, **Port forwarding runtime (0.1.27)** — saved local/remote/dynamic SOCKS5 rules actually start and stop (`PortForwardService` over the dartssh2 forward APIs behind a testable `TunnelTransport` abstraction); tunnels reuse the host's open SSH client or auto-connect with stored credentials (no terminal tab required), auto-reconnect with 2 s → 30 s exponential backoff keeping local listeners bound across drops, per-rule edit panel, auto-start on launch, live connection counters, inline error reporting, **SSH Agent Forwarding (0.1.28, #49)** — per-host toggle (like `ssh -A`); forwarded `auth-agent@openssh.com` channels are served by the local system agent (`SSH_AUTH_SOCK` / Windows OpenSSH agent pipe) with fallback to app-Keychain keys when no system agent is running; requested on shell channels only, server refusal shows a terminal warning instead of killing the session (`AgentForwardingHandler`, `SystemAgentProxy.roundtrip`, dartssh2 fork agent-channel support), **Strict KEX (0.1.29)** — CVE-2023-48795 "Terrapin" mitigation in the dartssh2 fork: sequence numbers reset after every NEWKEYS, non-KEX messages during the initial exchange terminate the connection, KEXINIT must be the first packet, **Quick wins (0.1.29)** — middle-click closes unpinned session tabs; right-click context menu on port-forward rules with Duplicate (new id, "(copy)" label, auto-start off); distro-level OS icons (`/etc/os-release` ID → ubuntu/debian/fedora/centos/rocky/alma/alpine/amazon/arch/suse/redhat glyphs) on the hosts dashboard and SSH session tabs (`os_detection.dart`, `SessionTab` extracted from main_screen); empty-password SSH behavior locked in by tests (blank passwords are never persisted; connect sends ''), **SFTP permissions editor + unified entry context menu (0.1.29)** — chmod dialog (9-checkbox rwx grid two-way synced with a validated octal field: octal-only input, 3–4 digits, Apply gated while invalid; unknown current permissions fall back to stat() then warn and gate Apply instead of offering 000) on both the remote SFTP and local panels; `SftpFileOpsService.chmod` with a hardened recursive walk (entries with omitted modes classified via lstat, symlinks never followed — SETSTAT would chmod the target, directory modes applied post-order so a restrictive mode can't lock the walk out, file chmods batched 8-wide) sharing its `listWalkChildren` classification with recursive delete; st_mode carried on `SftpEntry`/`LocalEntry` from listing/scan time (no blocking stat at dialog-open); one shared `EntryContextMenu` for both panels (Open / Open with / View / Edit / Copy to target with up-front feasibility reasons incl. the same-folder block / Refresh / New folder / Permissions / Rename / Delete) wired through the dual-panel transfer matrix; shared app-launch helpers extracted to `util/app_launcher.dart`, **Bulk action panel (0.1.30)** — SELECT mode on the hosts dashboard (per-card checkboxes, filter-aware Select all, Esc to exit) with Connect all (skips already-connected hosts, confirms before opening more than 5 tabs), Run command in parallel (free text or snippet; bounded concurrency, 30 s per-host timeout, per-host failure isolation; per-host exit code / duration / expandable stdout+stderr; a Diff tab groups identical outputs against a promotable baseline and side-by-side compares any two hosts), and Push files to one remote path on every host (destination created if missing, per-host byte progress, cancel); closing a dialog mid-run confirms, cancels queued hosts and lets in-flight operations record their real result, **Dashboard grid & list view + sorting (0.1.30)** — card grid ↔ compact one-line list toggle and a sort dropdown (name / creation date / hostname, asc/desc), both persisted across restarts; default order Name A–Z, **Agent forwarding observability (0.1.30)** — pre-connect agent status line in the host panel (system agent identities / app-Keychain fallback / nothing to serve), live per-session key icon on the session tab (grey ready / green active / orange Keychain fallback / red refused), and a notification-bell item with tap-to-jump when the server refuses forwarding, **Connection Chain editor (0.1.30)** — Termius-style visual chain replacing the jump-host dropdown in the host panel (bastion card → arrow → destination, searchable Add-a-Host picker, agent-forwarding key icon, Clear; `HostChainEditor`, single hop), **Jump host on auto-connect paths (0.1.30)** — `ensureClient` (SFTP / exec / port forwarding) now resolves `Host.jumpHostId` via `defaultJumpHostLookup`, so hosts behind a bastion tunnel correctly outside interactive sessions; plus `tool/jump_probe.dart`, a layer-by-layer jump diagnostic CLI, **Session template / per-host preset (0.1.32)** — per-host working dir + env vars delivered invisibly via the shell-integration handshake (tty canonical mode lifted during the payload read so large templates don't truncate at the 4096-byte line cap), startup snippet typed visibly after the DONE sentinel (skipped under tmux and on handshake abort — a re-attach must not replay it), and per-host terminal theme / font / size / TERM / tmux overrides falling back to globals (out-of-range synced values rejected at render); SESSION TEMPLATE section in the host panel with env-key validation incl. duplicate-name detection, **Internal audit log (0.1.32)** — local SQLite (`sqlite3`, WAL, `/audit.db`) trail of connect/disconnect/exec/input events with per-caller source tagging (`bulk` / `devops` / `plugin:` / `input-bar` / broadcast targets each get their own row; internal polls like network stats and OS detection are exempt), secret redaction before insert (key=value incl. quoted multi-word values, Bearer tokens, sshpass/mysql-family `-p`, redis-cli `-a`, URL userinfo — applied to meta error strings too), Audit Log screen with type/time/search filters, keyset pagination, CSV/JSON export (macOS sandbox entitlement upgraded to user-selected read-write), retention pruning at launch (default 90 days, configurable in Settings → Audit) and fail-soft writes that can never break an SSH operation, **In-app SSH key generation (0.1.32)** — generate Ed25519 (pure Dart in the dartssh2 fork: `OpenSSHEd25519KeyPair.generate()` + passphrase-encrypting `toPem` via bcrypt-pbkdf + aes256-ctr, interop-verified against real `ssh-keygen -y`) / RSA-4096 / ECDSA-P256 (via system `ssh-keygen`; options gated by a binary probe) from the Keychain screen; keys land in `Documents/YourSSH/keys` mode 600 with the passphrase in secure storage; copy public key + ssh-copy-id-style **deploy-to-host dialog** (idempotent `grep -qxF`, EXISTS/ADDED markers) — includes the deploy stretch goal, **Local shell picker (0.1.32)** — choose which shell local terminal tabs run: auto-detected per platform (Windows: PowerShell, cmd, PowerShell 7, Git Bash, one profile per WSL distro; macOS/Linux: `$SHELL` + `/etc/shells`) plus user-added custom executables with arguments; default in Settings → Terminal, per-session choice in the new-tab (+) menu, Restart shell reuses the session's shell, an uninstalled default falls back to the platform default with a terminal warning (`ShellProfile`, `shell_detection.dart`), **Multi-hop jump chain (0.1.32)** — bastion → bastion → target for layered networks: `Host.jumpHostIds` ordered chain (legacy `jumpHostId` migrates and is dual-written to JSON for cross-version sync), `SshService` dials hop-over-hop via `forwardLocal` with chain-prefix-keyed client cache, deepest-first refcounted teardown and a cycle guard, per-hop host-key verification; `HostChainEditor` appends/removes hops (persistent Add a Host, per-hop ×, Clear); `ensureClient` and test-connection resolve the full chain, **Recording redaction (0.1.32)** — secrets masked before being written to `.cast`: line-buffered redaction in `RecordingService` reusing `AuditRedactor` unchanged (split at the last newline, start-once 500 ms flush timer, stop flushes the tail; per-line coalescing also strips keystroke timing), global Settings toggle AND per-host opt-out (both default on; pure `effectiveRecordingRedaction` policy sampled once at record start with a fresh `HostProvider` lookup), **In-app RDP client (0.1.33, #44)** — Windows / xrdp desktops as first-class tabs: IronRDP Rust engine via flutter_rust_bridge v2 (`packages/yourssh_rdp`), NLA/TLS/auto security, direct or SSH-tunneled through a saved jump host (full connection chain reused), TOFU certificate pinning enforced in Rust pre-CredSSP (changed cert aborts before credentials are sent), server-negotiated resolution handling, bidirectional clipboard, full keyboard/mouse incl. Ctrl+Alt+Del, OS-level fullscreen with mstsc-style hover pill + auto-exit safety, graceful server-initiated disconnect (MCS ultimatum → clean message, not a raw protocol error), protocol-aware dashboard actions, RDP badge, tab parity (rename/color/pin/restore/audit/notification bell), **Kubernetes panel (0.1.34)** — `KubernetesPanel` with context switcher, `kubectl logs -f`, and 1-click port-forward via `ContainerService.execStream`, **Keyword highlighting (0.1.34)** — user-defined regex rules with per-rule color picker (defaults: Error/Warning/Fail in red/yellow/cyan); rendered in the xterm fork at paint time (`paintKeywordForeground`, `KeywordHighlightRule`) wired through `TerminalView → RenderTerminal`; toggle + rule list + add/edit dialog in Settings → Terminal and the terminal config side panel, **Server monitor panel (0.1.34)** — per-host live dashboard (CPU/mem/disk/uptime/listening ports/firewall) via draggable bottom sheet; accessed from host card hover button and context menu; `SystemStatsService` polls every 5 s via compound SSH exec with sentinel markers (`__CPU1__`/`__CPU2__`/`__MEM__`/`__DISK__`/`__UPTIME__`/`__PORTS__`), `FirewallStatusService` polls every 30 s (ufw/iptables-save/nftables auto-detected); pure parser models `SystemSnapshot` + `FirewallStatus`; requires an active SSH session for the host, **Network discovery (0.1.34)** — scan local network for SSH/RDP hosts without typing an IP; `NetworkDiscoveryService` combines mDNS (`_ssh._tcp` / `_rdp._tcp`) and a TCP port scan on the local subnet; results in a draggable bottom sheet with one-tap Add Host; also linked from the Add Host panel; `DiscoveredHost` model + `multicast_dns` dep, **Import sources expansion (0.1.34)** — five new import formats beyond SSH config/JSON/CSV: PuTTY `.reg` (hex port, URL-decoded names), MobaXterm `.mxtsessions` (SSH type-0 only, multi-`[Bookmarks_N]` files), SecureCRT XML (recursive folder → group path), Ansible INI inventory (`ansible_host`/`ansible_user`/`ansible_port`; `:vars`/`:children` skipped), WinSCP `.ini` (URL-decoded, nested path → label + group); **Known hosts import** from `~/.ssh/known_hosts` via the Known Hosts screen (MD5(key\_blob) fingerprints, duplicate-skip, hashed-entry skip), **SFTP breadcrumb path jump (0.1.35, #62)** — inline path editor on the shared `PathBreadcrumb` (edit affordance → type path → Enter; Esc cancels) in both the remote SFTP and local panels; remote input normalized to absolute POSIX, **macOS universal build (0.1.35, #64)** — Intel + Apple Silicon from one arm64+x86_64 artifact: `build.sh` lipos both Rust dylib targets, the release workflow asserts both arches via `lipo -archs`, and the updater matches the universal DMG on both archs (browser fallback on pre-universal releases for Intel), **Perf & size pass (0.1.35, #63)** — memoized keyword-rule compilation in `SettingsProvider`, `setActive` equality guard, direct-buffer agent message build; dropped unused `local_auth` dep and MesloLGS NF italic faces (−4.8 MB per bundle); `--split-debug-info` on all desktop release builds with per-release symbols zips, **Terminal scroll & rendering overhaul (0.1.36, #65 #66 #67)** — mouse wheel works inside mouse-aware TUIs (claude CLI, htop, vim `mouse=a`, tmux mouse on; wheel buttons were reported as 68/69 instead of the spec's 64/65), decomposed (NFD) Vietnamese text composes into precomposed cells (macOS `ls` filenames render correctly), scrollback position stays pinned while reading during fast output at the `maxLines` cap, per-line picture render cache (~7× less per-frame paint work; keyword regexes run on line change instead of every frame), Shift+PageUp/PageDown scrollback paging, and a right-click **Reset Terminal** action that recovers sessions stuck in the alternate screen after a TUI died uncleanly. -**Shipped (0.1.37):** **OSC 52 clipboard** — remote apps (tmux, vim) write to the local system clipboard via the OSC 52 escape sequence; write-only, per-host opt-in (`Host.osc52Clipboard`) for safety; pure payload parser + dispatch on the xterm `onPrivateOSC` path (`osc52_clipboard.dart`). **In-app VNC client (VNC M1–M5, #44 family)** — Linux VNC servers (TigerVNC / x11vnc / TightVNC) as first-class tabs, mirroring the RDP integration one-to-one: `packages/yourssh_vnc` Rust crate over `vnc-rs` via flutter_rust_bridge v2; RFB connect + None / VNC-password auth, framebuffer-update rendering with latest-wins decode (`VncSession`), mouse + keyboard input (X11 keysym map + pointer-coordinate transform), bidirectional clipboard (Client/ServerCutText), server-driven auto-resize, SSH tunneling through the shared loopback proxy (`RdpTunnelProxy` generalized to `LoopbackTunnelProxy`), fullscreen with mstsc-style auto-hide hover pill, `ProtocolBadge` (RDP + VNC), `HostProtocol.vnc` (port 5900) with a VNC mode in the host panel, protocol-aware dashboard actions + `vnc://` copy-url, tab parity (rename / color / pin / restore / audit / notification bell), and a manual integration-screenshots test. +**Shipped (0.1.37):** **OSC 52 clipboard** — remote apps (tmux, vim) write to the local system clipboard via the OSC 52 escape sequence; write-only, per-host opt-in (`Host.osc52Clipboard`) for safety; pure payload parser + dispatch on the xterm `onPrivateOSC` path (`osc52_clipboard.dart`). **In-app VNC client (VNC M1–M5, #44 family)** — Linux VNC servers (TigerVNC / x11vnc / TightVNC) as first-class tabs, mirroring the RDP integration one-to-one: `packages/yourssh_vnc` Rust crate over `vnc-rs` via flutter_rust_bridge v2; RFB connect + None / VNC-password auth, framebuffer-update rendering with latest-wins decode (`VncSession`), mouse + keyboard input (X11 keysym map + pointer-coordinate transform), bidirectional clipboard (Client/ServerCutText), server-driven auto-resize, SSH tunneling through the shared loopback proxy (`RdpTunnelProxy` generalized to `LoopbackTunnelProxy`), fullscreen with mstsc-style auto-hide hover pill, `ProtocolBadge` (RDP + VNC), `HostProtocol.vnc` (port 5900) with a VNC mode in the host panel, protocol-aware dashboard actions + `vnc://` copy-url, tab parity (rename / color / pin / restore / audit / notification bell), and a manual integration-screenshots test. **Connection proxy support** — per-host HTTP CONNECT and SOCKS5 outbound proxy for SSH connections on restricted networks; optional username/password auth (HTTP Basic + SOCKS5 RFC 1929); proxy password in secure storage, never in synced JSON; applies to local-originated dials (direct target + first bastion hop) so it composes correctly with the multi-hop jump chain; SOCKS5 sends the target hostname as a domain address (ATYP `0x03`) for remote DNS; pure Dart implementation (`proxy_handshake.dart`, `ConnectionProxy`, `SshService.localDial`) with injectable dial seams for tests; PROXY section in the host panel (type dropdown + host/port/username/password, SSH-only — RDP/VNC use SSH tunneling). + +**Shipped (0.1.38):** **Docker panel completion** — the Containers screen gains dedicated **Docker** and **Compose** tabs beside Kubernetes (`DockerPanel` / `ComposePanel`, extracted from `ContainersScreen`). Docker: live container list (`docker ps -a`) with per-container Stop / Start / Restart, one-click Exec into a new SSH tab, and an inline follow-mode log viewer (`docker logs -f --tail`, auto-scroll with manual-scroll detach, 2000-line cap). Compose (v2): stack discovery via `docker compose ls` merged with a `find` sweep of `~ /opt /srv /home` (ls entries win on dir collision) plus manual compose-file path add; per-stack Up / Down, per-service Start / Stop with replica counts, and per-service follow-mode logs. New `ComposeStack` / `ComposeService` models and `ContainerService` methods (`streamDockerLogs`, `stop`/`start`/`restartContainer`, `discoverComposeStacks`, `listComposeServices`, `composeUp`/`composeDown`, `start`/`stopComposeService`, `streamComposeServiceLogs`) with pure static parsers (`parseComposeLs` / `parseComposeFindOutput` / `parseComposePs`); all exec/stream calls tagged `auditSource: 'devops'` and shell-quote project paths. --- @@ -18,7 +20,6 @@ _All P0 items shipped. See P1 for next priorities._ ## P1 — Differentiation & DevOps depth ### Workflow & integrations -- **Docker / Compose panel** — _list containers + exec shipped (0.1.12); K8s logs + port-forward shipped (0.1.34)._ Remaining Docker: logs, restart/stop, Compose awareness. - **systemd / service browser** — list units, status, `journalctl` tail. - **Log tail viewer** — multi-file `tail -f` with highlight rules, level filter, pause/resume, save view. - **Cloud inventory import** — AWS/GCP/Azure → auto-sync host list by instance tags, refresh on demand. @@ -39,7 +40,6 @@ _All P0 items shipped. See P1 for next priorities._ - **Biometric-protected keys** — private keys gated behind Touch ID / Windows Hello (Secure Enclave / TPM-backed where available) instead of a stored passphrase. - **TOTP autofill for keyboard-interactive** — store a per-host TOTP secret in secure storage and auto-answer 2FA prompts during auth. - **Post-quantum crypto** — `mlkem768x25519-sha256` KEX and ML-DSA keys in the dartssh2 fork, tracking OpenSSH 9.x+ defaults. -- **Connection proxy support** — HTTP CONNECT / SOCKS5 proxy option per host for restricted networks (complements the existing jump-host chain). - **Auto key rotation reminder** + age widget per key. ### AI-native (extending AI chat sidebar) @@ -73,9 +73,9 @@ _All P0 items shipped. See P1 for next priorities._ ## Top 3 suggestions for the next sprint -1. **Connection proxy support** (P1 Security) — HTTP CONNECT / SOCKS5 per host for restricted networks; the natural complement to the shipped multi-hop jump chain. -2. **Docker panel completion** (P1 Workflow) — logs, restart/stop, Compose awareness; finishes the container story (browser + exec shipped 0.1.12, K8s logs + port-forward shipped 0.1.34). -3. **Log tail viewer** (P1 Terminal UX) — multi-file `tail -f` with highlight rules, level filter, pause/resume, save view; pairs naturally with the just-shipped keyword highlighting. +1. **Log tail viewer** (P1 Terminal UX) — multi-file `tail -f` with highlight rules, level filter, pause/resume, save view; pairs naturally with the shipped keyword highlighting. +2. **systemd / service browser** (P1 Workflow) — list units, status, `journalctl` tail; extends the infra-ops surface now that the Docker + Kubernetes container story is complete (0.1.38). +3. **Secrets vault adapter** (P1 Security) — 1Password / Bitwarden / HashiCorp Vault / aws-vault instead of storing passwords in the app; natural next step now that the connection proxy and multi-hop chain are shipped. --- diff --git a/docs/superpowers/plans/2026-06-18-docker-panel-completion.md b/docs/superpowers/plans/2026-06-18-docker-panel-completion.md new file mode 100644 index 00000000..359b9616 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-docker-panel-completion.md @@ -0,0 +1,2118 @@ +# Docker Panel Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Docker logs, container lifecycle actions (stop/start/restart), and Compose awareness (stack discovery, service management, per-service logs) to the Containers screen. + +**Architecture:** Extend `ContainerService` with new methods and static parsers; extract `DockerPanel` from `ContainersScreen`; add `ComposePanel`; wire a new Compose tab. All service methods are tested with a `_FakeSshService` stub; widget tests verify renders and basic interactions. + +**Tech Stack:** Flutter/Dart, `SshService.exec` returns `({String stdout, String stderr, int exitCode})`, `SshService.execStream` returns `Stream`, `dart:convert` for JSON, `provider` package for session/service access. + +**Spec:** `docs/superpowers/specs/2026-06-18-docker-panel-completion-design.md` + +## Global Constraints + +- Every `ssh.exec` / `ssh.execStream` call in `ContainerService` uses `auditSource: 'devops'`. +- All non-zero exit codes throw `Exception(stderr.trim().isEmpty ? ' failed' : stderr.trim())`. +- Log lines capped at 2000 per panel; oldest dropped on overflow (`if (_logLines.length > 2000) _logLines.removeRange(0, _logLines.length - 2000)`). +- `docker compose` = Docker Compose v2 subcommand. v1 (`docker-compose`) is not supported; show a hint card. +- All static parse methods return empty lists (never throw) on malformed input. +- Flutter test command: run from `app/` directory: `flutter test `. +- Shell-quote project paths using single quotes in all `cd '...' && docker compose ...` commands. + +--- + +## File Map + +| File | Action | Responsibility | +|------|--------|----------------| +| `app/lib/models/container_entry.dart` | Modify | Add `ComposeStack`, `ComposeService` models | +| `app/lib/services/container_service.dart` | Modify | Add 11 new methods + 3 static parsers | +| `app/lib/widgets/containers_screen.dart` | Modify | Add `_Tab.compose`; render `DockerPanel` / `ComposePanel` instead of `_dockerList()` | +| `app/lib/widgets/docker_panel.dart` | Create | Container list + lifecycle buttons + inline log panel | +| `app/lib/widgets/compose_panel.dart` | Create | Stack discovery + service list + inline log panel | +| `app/test/services/container_service_docker_test.dart` | Create | Unit tests: Docker lifecycle methods + command strings | +| `app/test/services/container_service_compose_test.dart` | Create | Unit tests: Compose static parsers + discovery merge | + +--- + +## Task 1: Add ComposeStack and ComposeService models + +**Files:** +- Modify: `app/lib/models/container_entry.dart` + +**Interfaces:** +- Produces: `ComposeStack`, `ComposeService` — used by Tasks 2, 3, 4, 5 + +- [ ] **Step 1: Add models to `container_entry.dart`** + +Append after the last class in the file: + +```dart +/// One Docker Compose project discovered on the remote host. +class ComposeStack { + final String name; + final String projectDir; + final String status; + + const ComposeStack({ + required this.name, + required this.projectDir, + required this.status, + }); +} + +/// One service within a Docker Compose stack. +class ComposeService { + final String name; + final String status; + final String image; + final int replicas; + + const ComposeService({ + required this.name, + required this.status, + required this.image, + this.replicas = 1, + }); +} +``` + +- [ ] **Step 2: Verify the app still compiles** + +```bash +cd app && flutter analyze --no-fatal-infos 2>&1 | grep -E "error:|warning:" | head -20 +``` + +Expected: no errors. + +- [ ] **Step 3: Commit** + +```bash +git add app/lib/models/container_entry.dart +git commit -m "feat(docker): add ComposeStack and ComposeService models" +``` + +--- + +## Task 2: ContainerService — Docker logs + lifecycle methods + +**Files:** +- Modify: `app/lib/services/container_service.dart` +- Create: `app/test/services/container_service_docker_test.dart` + +**Interfaces:** +- Consumes: `Host` (existing), `SshService.exec` / `SshService.execStream` (existing) +- Produces: + - `streamDockerLogs(Host host, String id, {int tail = 100}) → Stream` + - `stopContainer(Host host, String id) → Future` + - `startContainer(Host host, String id) → Future` + - `restartContainer(Host host, String id) → Future` + +- [ ] **Step 1: Write the failing tests** + +Create `app/test/services/container_service_docker_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/container_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// Minimal SshService stub: override only exec and execStream. +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + ({String stdout, String stderr, int exitCode}) Function(String cmd)? execStub; + Stream Function(String cmd)? streamStub; + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String cmd, {String? auditSource}) async => + execStub?.call(cmd) ?? (stdout: '', stderr: '', exitCode: 0); + + @override + Stream execStream(Host host, String cmd, {String? auditSource}) => + streamStub?.call(cmd) ?? const Stream.empty(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + final host = Host(label: 'h', host: 'srv', port: 22, username: 'u'); + + group('streamDockerLogs', () { + test('passes correct command with tail flag and stderr merge', () { + final fake = _FakeSshService(); + String? capturedCmd; + fake.streamStub = (cmd) { + capturedCmd = cmd; + return const Stream.empty(); + }; + final svc = ContainerService(fake); + svc.streamDockerLogs(host, 'abc123', tail: 50); + expect(capturedCmd, 'docker logs -f --tail=50 abc123 2>&1'); + }); + + test('default tail is 100', () { + final fake = _FakeSshService(); + String? capturedCmd; + fake.streamStub = (cmd) { capturedCmd = cmd; return const Stream.empty(); }; + ContainerService(fake).streamDockerLogs(host, 'xyz'); + expect(capturedCmd, 'docker logs -f --tail=100 xyz 2>&1'); + }); + }); + + group('stopContainer', () { + test('does not throw on exit 0', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + await expectLater(ContainerService(fake).stopContainer(host, 'c1'), completes); + }); + + test('throws Exception with stderr on exit 1', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'No such container: c1', exitCode: 1); + await expectLater( + ContainerService(fake).stopContainer(host, 'c1'), + throwsA(isA().having( + (e) => e.toString(), 'message', contains('No such container')))); + }); + + test('passes correct command', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).stopContainer(host, 'abc'); + expect(cmd, 'docker stop abc'); + }); + }); + + group('startContainer', () { + test('does not throw on exit 0', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + await expectLater(ContainerService(fake).startContainer(host, 'c1'), completes); + }); + + test('throws on exit 1', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'Error response', exitCode: 1); + await expectLater(ContainerService(fake).startContainer(host, 'c1'), + throwsException); + }); + }); + + group('restartContainer', () { + test('passes correct command', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).restartContainer(host, 'abc'); + expect(cmd, 'docker restart abc'); + }); + + test('throws on failure', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'oops', exitCode: 1); + await expectLater(ContainerService(fake).restartContainer(host, 'c1'), + throwsException); + }); + }); +} +``` + +- [ ] **Step 2: Run tests — expect failure** + +```bash +cd app && flutter test test/services/container_service_docker_test.dart 2>&1 | tail -10 +``` + +Expected: compilation error — `streamDockerLogs`, `stopContainer`, etc. not found. + +- [ ] **Step 3: Add methods to `ContainerService`** + +In `app/lib/services/container_service.dart`, add a new section after `// ── Docker ────` (after `listDockerContainers`): + +```dart + // ── Docker logs + lifecycle ─────────────────────────── + + /// Streams stdout+stderr of `docker logs -f `. + Stream streamDockerLogs(Host host, String id, {int tail = 100}) => + ssh.execStream(host, 'docker logs -f --tail=$tail $id 2>&1', + auditSource: 'devops'); + + Future stopContainer(Host host, String id) async { + final r = await ssh.exec(host, 'docker stop $id', auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception(r.stderr.trim().isEmpty ? 'docker stop failed' : r.stderr.trim()); + } + } + + Future startContainer(Host host, String id) async { + final r = await ssh.exec(host, 'docker start $id', auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception(r.stderr.trim().isEmpty ? 'docker start failed' : r.stderr.trim()); + } + } + + Future restartContainer(Host host, String id) async { + final r = await ssh.exec(host, 'docker restart $id', auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception(r.stderr.trim().isEmpty ? 'docker restart failed' : r.stderr.trim()); + } + } +``` + +- [ ] **Step 4: Run tests — expect all pass** + +```bash +cd app && flutter test test/services/container_service_docker_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/services/container_service.dart \ + app/test/services/container_service_docker_test.dart +git commit -m "feat(docker): container logs streaming + stop/start/restart" +``` + +--- + +## Task 3: ContainerService — Compose discovery + actions + +**Files:** +- Modify: `app/lib/services/container_service.dart` +- Create: `app/test/services/container_service_compose_test.dart` + +**Interfaces:** +- Consumes: `ComposeStack`, `ComposeService` (Task 1), `_FakeSshService` pattern (Task 2 test file pattern — reproduce it here) +- Produces: + - `static List parseComposeLs(String json)` + - `static List parseComposeFindOutput(String stdout)` + - `static List parseComposePs(String stdout)` + - `discoverComposeStacks(Host) → Future>` + - `listComposeServices(Host, ComposeStack) → Future>` + - `composeUp(Host, ComposeStack) → Future` + - `composeDown(Host, ComposeStack) → Future` + - `startComposeService(Host, ComposeStack, String) → Future` + - `stopComposeService(Host, ComposeStack, String) → Future` + - `streamComposeServiceLogs(Host, ComposeStack, String, {int tail}) → Stream` + +- [ ] **Step 1: Write the failing tests** + +Create `app/test/services/container_service_compose_test.dart`: + +```dart +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/container_entry.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/container_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; + +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + ({String stdout, String stderr, int exitCode}) Function(String cmd)? execStub; + Stream Function(String cmd)? streamStub; + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String cmd, {String? auditSource}) async => + execStub?.call(cmd) ?? (stdout: '', stderr: '', exitCode: 0); + + @override + Stream execStream(Host host, String cmd, {String? auditSource}) => + streamStub?.call(cmd) ?? const Stream.empty(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + final host = Host(label: 'h', host: 'srv', port: 22, username: 'u'); + + // ── parseComposeLs ───────────────────────────────────── + + group('parseComposeLs', () { + test('parses valid JSON array', () { + const json = '[{"Name":"proj","Status":"running(2)","ConfigFiles":"/opt/proj/compose.yml"}]'; + final stacks = ContainerService.parseComposeLs(json); + expect(stacks.length, 1); + expect(stacks[0].name, 'proj'); + expect(stacks[0].projectDir, '/opt/proj'); + expect(stacks[0].status, 'running(2)'); + }); + + test('returns empty list for empty array', () { + expect(ContainerService.parseComposeLs('[]'), isEmpty); + }); + + test('returns empty list for malformed JSON', () { + expect(ContainerService.parseComposeLs('not-json'), isEmpty); + }); + + test('returns empty list for empty string', () { + expect(ContainerService.parseComposeLs(''), isEmpty); + }); + + test('uses dirname of first ConfigFiles entry as projectDir', () { + const json = '[{"Name":"x","Status":"exited(1)","ConfigFiles":"/srv/app/docker-compose.yml,/srv/app/override.yml"}]'; + final stacks = ContainerService.parseComposeLs(json); + expect(stacks[0].projectDir, '/srv/app'); + }); + }); + + // ── parseComposeFindOutput ────────────────────────────── + + group('parseComposeFindOutput', () { + test('parses find output into stacks', () { + const output = '/home/user/myapp/docker-compose.yml\n/opt/blog/compose.yml\n'; + final stacks = ContainerService.parseComposeFindOutput(output); + expect(stacks.length, 2); + expect(stacks[0].projectDir, '/home/user/myapp'); + expect(stacks[0].name, 'myapp'); + expect(stacks[0].status, 'unknown'); + expect(stacks[1].projectDir, '/opt/blog'); + }); + + test('deduplicates by projectDir', () { + const output = + '/opt/proj/docker-compose.yml\n/opt/proj/docker-compose.override.yml\n'; + final stacks = ContainerService.parseComposeFindOutput(output); + expect(stacks.length, 1); + }); + + test('returns empty list for empty output', () { + expect(ContainerService.parseComposeFindOutput(''), isEmpty); + }); + }); + + // ── parseComposePs ────────────────────────────────────── + + group('parseComposePs', () { + test('parses JSONL output (one JSON object per line)', () { + const output = + '{"Name":"myapp-web-1","Service":"web","State":"running","Image":"nginx:latest"}\n' + '{"Name":"myapp-db-1","Service":"db","State":"running","Image":"postgres:15"}\n'; + final services = ContainerService.parseComposePs(output); + expect(services.length, 2); + expect(services[0].name, 'web'); + expect(services[0].status, 'running'); + expect(services[0].image, 'nginx:latest'); + }); + + test('parses JSON array output', () { + const output = + '[{"Name":"myapp-web-1","Service":"web","State":"running","Image":"nginx:latest"}]'; + final services = ContainerService.parseComposePs(output); + expect(services.length, 1); + expect(services[0].name, 'web'); + }); + + test('aggregates replicas for same service name', () { + const output = + '{"Name":"app-worker-1","Service":"worker","State":"running","Image":"myimg"}\n' + '{"Name":"app-worker-2","Service":"worker","State":"running","Image":"myimg"}\n'; + final services = ContainerService.parseComposePs(output); + expect(services.length, 1); + expect(services[0].replicas, 2); + }); + + test('returns empty list for empty output', () { + expect(ContainerService.parseComposePs(''), isEmpty); + }); + + test('returns empty list for malformed JSON', () { + expect(ContainerService.parseComposePs('garbage'), isEmpty); + }); + }); + + // ── discoverComposeStacks dedup ───────────────────────── + + group('discoverComposeStacks', () { + test('deduplicates by projectDir: ls result takes precedence over find', () async { + final fake = _FakeSshService(); + // First cmd: docker compose ls; second: find + var callCount = 0; + fake.execStub = (cmd) { + callCount++; + if (cmd.contains('docker compose ls')) { + return ( + stdout: '[{"Name":"myapp","Status":"running(1)","ConfigFiles":"/opt/myapp/compose.yml"}]', + stderr: '', + exitCode: 0, + ); + } + // find output — same projectDir + return (stdout: '/opt/myapp/compose.yml\n', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + final stacks = await svc.discoverComposeStacks(host); + // Both sources found /opt/myapp — should appear once only, with status from ls + final myapp = stacks.where((s) => s.projectDir == '/opt/myapp').toList(); + expect(myapp.length, 1); + expect(myapp[0].status, 'running(1)'); + }); + }); + + // ── compose actions ───────────────────────────────────── + + group('composeUp', () { + test('runs docker compose up -d in projectDir', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + final stack = ComposeStack(name: 'app', projectDir: '/opt/app', status: 'exited'); + await ContainerService(fake).composeUp(host, stack); + expect(cmd, "cd '/opt/app' && docker compose up -d"); + }); + + test('throws on non-zero exit', () async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: 'pull error', exitCode: 1); + await expectLater( + ContainerService(fake).composeUp(host, + ComposeStack(name: 'a', projectDir: '/p', status: 'x')), + throwsException); + }); + }); + + group('composeDown', () { + test('runs docker compose down in projectDir', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).composeDown( + host, ComposeStack(name: 'a', projectDir: '/srv/a', status: 'x')); + expect(cmd, "cd '/srv/a' && docker compose down"); + }); + }); + + group('startComposeService / stopComposeService', () { + test('start passes service name', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).startComposeService( + host, ComposeStack(name: 'app', projectDir: '/p', status: 'x'), 'web'); + expect(cmd, "cd '/p' && docker compose start web"); + }); + + test('stop passes service name', () async { + final fake = _FakeSshService(); + String? cmd; + fake.execStub = (c) { cmd = c; return (stdout: '', stderr: '', exitCode: 0); }; + await ContainerService(fake).stopComposeService( + host, ComposeStack(name: 'app', projectDir: '/p', status: 'x'), 'web'); + expect(cmd, "cd '/p' && docker compose stop web"); + }); + }); + + group('streamComposeServiceLogs', () { + test('passes correct command with tail and service name', () { + final fake = _FakeSshService(); + String? cmd; + fake.streamStub = (c) { cmd = c; return const Stream.empty(); }; + ContainerService(fake).streamComposeServiceLogs( + host, ComposeStack(name: 'app', projectDir: '/opt/app', status: 'x'), 'web', + tail: 50); + expect(cmd, "cd '/opt/app' && docker compose logs -f --tail=50 web 2>&1"); + }); + }); +} +``` + +- [ ] **Step 2: Run tests — expect failure** + +```bash +cd app && flutter test test/services/container_service_compose_test.dart 2>&1 | tail -10 +``` + +Expected: compilation errors — methods not found. + +- [ ] **Step 3: Add static parsers to `ContainerService`** + +Add after `parseDockerPs` in `container_service.dart`. Also add `dart:convert` to imports if not present: + +```dart +import 'dart:convert'; +``` + +Then add the static parsers: + +```dart + // ── Compose static parsers ──────────────────────────── + + /// Parses `docker compose ls --format json` output. + /// Returns [] on any parse failure. + static List parseComposeLs(String json) { + if (json.trim().isEmpty) return const []; + try { + final list = jsonDecode(json) as List; + return list.map((e) { + final map = e as Map; + final configFiles = (map['ConfigFiles'] as String? ?? '').trim(); + // ConfigFiles may be comma-separated; take the first to derive projectDir. + final firstFile = configFiles.split(',').first.trim(); + final projectDir = firstFile.isNotEmpty + ? firstFile.substring(0, firstFile.lastIndexOf('/')) + : ''; + return ComposeStack( + name: (map['Name'] as String? ?? '').trim(), + projectDir: projectDir, + status: (map['Status'] as String? ?? '').trim(), + ); + }).where((s) => s.name.isNotEmpty && s.projectDir.isNotEmpty).toList(); + } catch (_) { + return const []; + } + } + + /// Parses `find ... -name "docker-compose.yml" -o -name "compose.yml"` output. + /// Deduplicates by projectDir. + static List parseComposeFindOutput(String stdout) { + final seen = {}; + final out = []; + for (final line in stdout.split('\n')) { + final path = line.trim(); + if (path.isEmpty) continue; + final lastSlash = path.lastIndexOf('/'); + if (lastSlash < 0) continue; + final projectDir = path.substring(0, lastSlash); + if (seen.contains(projectDir)) continue; + seen.add(projectDir); + final name = projectDir.substring(projectDir.lastIndexOf('/') + 1); + out.add(ComposeStack(name: name, projectDir: projectDir, status: 'unknown')); + } + return out; + } + + /// Parses `docker compose ps --format json` output (JSON array or JSONL). + /// Groups by service name, counts replicas. + static List parseComposePs(String stdout) { + if (stdout.trim().isEmpty) return const []; + final items = >[]; + try { + // Try JSON array first. + final decoded = jsonDecode(stdout.trim()); + if (decoded is List) { + for (final e in decoded) { + if (e is Map) items.add(e); + } + } + } catch (_) { + // Fall through to JSONL. + } + if (items.isEmpty) { + // Try JSONL (one JSON object per line). + for (final line in stdout.split('\n')) { + final t = line.trim(); + if (t.isEmpty) continue; + try { + final obj = jsonDecode(t); + if (obj is Map) items.add(obj); + } catch (_) { + continue; + } + } + } + if (items.isEmpty) return const []; + + // Group by "Service" field, count replicas. + final byService = >>{}; + for (final item in items) { + final svc = (item['Service'] as String? ?? '').trim(); + if (svc.isEmpty) continue; + (byService[svc] ??= []).add(item); + } + return byService.entries.map((e) { + final first = e.value.first; + return ComposeService( + name: e.key, + status: (first['State'] as String? ?? '').trim(), + image: (first['Image'] as String? ?? '').trim(), + replicas: e.value.length, + ); + }).toList(); + } +``` + +- [ ] **Step 4: Add instance methods to `ContainerService`** + +Add a new section `// ── Compose ───` after the log-streaming section: + +```dart + // ── Compose ─────────────────────────────────────────── + + /// Discovers Compose stacks by running `docker compose ls` (active projects) + /// and `find` (files on disk). Results are merged; ls entries take precedence + /// when the same projectDir appears in both. + Future> discoverComposeStacks(Host host) async { + final results = await Future.wait([ + _composeLsStacks(host), + _composeFindStacks(host), + ]); + final lsStacks = results[0]; + final findStacks = results[1]; + // Merge: ls entries take precedence. + final seen = {for (final s in lsStacks) s.projectDir}; + return [ + ...lsStacks, + ...findStacks.where((s) => !seen.contains(s.projectDir)), + ]; + } + + Future> _composeLsStacks(Host host) async { + final r = await ssh.exec(host, 'docker compose ls --format json 2>/dev/null', + auditSource: 'devops'); + if (r.exitCode != 0) return const []; + return parseComposeLs(r.stdout); + } + + Future> _composeFindStacks(Host host) async { + const findCmd = + r"find ~ /opt /srv /home -maxdepth 3 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null"; + final r = await ssh.exec(host, findCmd, auditSource: 'devops'); + if (r.exitCode != 0) return const []; + return parseComposeFindOutput(r.stdout); + } + + Future> listComposeServices( + Host host, ComposeStack stack) async { + final r = await ssh.exec( + host, "cd '${stack.projectDir}' && docker compose ps --format json 2>/dev/null", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose ps failed' : r.stderr.trim()); + } + return parseComposePs(r.stdout); + } + + Future composeUp(Host host, ComposeStack stack) async { + final r = await ssh.exec( + host, "cd '${stack.projectDir}' && docker compose up -d", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose up failed' : r.stderr.trim()); + } + } + + Future composeDown(Host host, ComposeStack stack) async { + final r = await ssh.exec( + host, "cd '${stack.projectDir}' && docker compose down", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose down failed' : r.stderr.trim()); + } + } + + Future startComposeService( + Host host, ComposeStack stack, String service) async { + final r = await ssh.exec( + host, "cd '${stack.projectDir}' && docker compose start $service", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose start failed' : r.stderr.trim()); + } + } + + Future stopComposeService( + Host host, ComposeStack stack, String service) async { + final r = await ssh.exec( + host, "cd '${stack.projectDir}' && docker compose stop $service", + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception( + r.stderr.trim().isEmpty ? 'docker compose stop failed' : r.stderr.trim()); + } + } + + Stream streamComposeServiceLogs( + Host host, ComposeStack stack, String service, {int tail = 100}) => + ssh.execStream( + host, + "cd '${stack.projectDir}' && docker compose logs -f --tail=$tail $service 2>&1", + auditSource: 'devops'); +``` + +- [ ] **Step 5: Run tests — expect all pass** + +```bash +cd app && flutter test test/services/container_service_compose_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 6: Also run Docker tests to ensure no regression** + +```bash +cd app && flutter test test/services/container_service_docker_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 7: Commit** + +```bash +git add app/lib/services/container_service.dart \ + app/test/services/container_service_compose_test.dart +git commit -m "feat(docker): Compose discovery, service actions, and log streaming" +``` + +--- + +## Task 4: DockerPanel widget + +**Files:** +- Create: `app/lib/widgets/docker_panel.dart` +- Create: `app/test/widgets/docker_panel_test.dart` + +**Interfaces:** +- Consumes: `Host`, `ContainerService` (Tasks 1+2), `ContainerEntry` (existing), `SessionProvider` (for `connect`) +- Produces: `DockerPanel(host: Host, service: ContainerService, onConnect: Future Function(Host, String))` widget + +- [ ] **Step 1: Create `docker_panel.dart`** + +Create `app/lib/widgets/docker_panel.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/container_entry.dart'; +import '../models/host.dart'; +import '../providers/session_provider.dart'; +import '../services/container_service.dart'; +import '../theme/app_theme.dart'; + +class DockerPanel extends StatefulWidget { + const DockerPanel({super.key, required this.host, required this.service}); + + final Host host; + final ContainerService service; + + @override + State createState() => _DockerPanelState(); +} + +class _DockerPanelState extends State { + List _containers = []; + bool _loading = false; + String? _error; + + // Log panel state + ContainerEntry? _logContainer; + StreamSubscription? _logSub; + final List _logLines = []; + final ScrollController _logScroll = ScrollController(); + bool _autoScroll = true; + + // Per-container action loading + final Map _actionLoading = {}; + + @override + void initState() { + super.initState(); + _logScroll.addListener(_onLogScroll); + _refresh(); + } + + @override + void dispose() { + _logSub?.cancel(); + _logScroll.dispose(); + super.dispose(); + } + + void _onLogScroll() { + final pos = _logScroll.position; + _autoScroll = pos.pixels >= pos.maxScrollExtent - 8; + } + + void _scrollToBottom() { + if (!_autoScroll) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_logScroll.hasClients) { + _logScroll.jumpTo(_logScroll.position.maxScrollExtent); + } + }); + } + + Future _refresh() async { + setState(() { + _loading = true; + _error = null; + }); + try { + _containers = await widget.service.listDockerContainers(widget.host); + } catch (e) { + _error = e.toString(); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + bool _isRunning(ContainerEntry c) => + c.status.toLowerCase().startsWith('up'); + + Future _runAction(ContainerEntry c, Future Function() action) async { + setState(() => _actionLoading[c.id] = true); + try { + await action(); + await _refresh(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString()), backgroundColor: Colors.red.shade800), + ); + } + } finally { + if (mounted) setState(() => _actionLoading.remove(c.id)); + } + } + + void _openLogs(ContainerEntry c) { + _logSub?.cancel(); + setState(() { + _logContainer = c; + _logLines.clear(); + _autoScroll = true; + }); + _logSub = widget.service + .streamDockerLogs(widget.host, c.id) + .listen((line) { + if (mounted) { + setState(() { + _logLines.add(line); + if (_logLines.length > 2000) { + _logLines.removeRange(0, _logLines.length - 2000); + } + }); + _scrollToBottom(); + } + }, onDone: () { + if (mounted) { + setState(() => _logLines.add('— connection closed —')); + _scrollToBottom(); + } + }, onError: (e) { + if (mounted) { + setState(() => _logLines.add('— error: $e —')); + _scrollToBottom(); + } + }); + } + + void _closeLogs() { + _logSub?.cancel(); + _logSub = null; + setState(() { + _logContainer = null; + _logLines.clear(); + }); + } + + Future _execContainer(ContainerEntry c) async { + final sessionProvider = context.read(); + await sessionProvider.connect( + widget.host, + initialCommand: ContainerService.dockerExecCommand(c.id), + ); + } + + @override + Widget build(BuildContext context) { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.error_outline, size: 36, color: AppColors.textTertiary), + const SizedBox(height: 8), + Text(_error!, textAlign: TextAlign.center), + const SizedBox(height: 12), + FilledButton(onPressed: _refresh, child: const Text('Retry')), + ]), + ); + } + if (_containers.isEmpty) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.inbox, size: 36, color: AppColors.textTertiary), + const SizedBox(height: 8), + const Text('No running containers.'), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.refresh, size: 16), + label: const Text('Refresh'), + onPressed: _refresh), + ]), + ); + } + + final hasLogs = _logContainer != null; + return Column( + children: [ + // Header row + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 4), + child: Row( + children: [ + Text('${_containers.length} container${_containers.length == 1 ? '' : 's'}', + style: const TextStyle( + color: AppColors.textSecondary, fontSize: 12)), + const Spacer(), + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh, size: 16), + onPressed: _refresh, + ), + ], + ), + ), + // Container list + Expanded( + flex: hasLogs ? 1 : 2, + child: ListView.separated( + itemCount: _containers.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: AppColors.border), + itemBuilder: (_, i) => _containerTile(_containers[i]), + ), + ), + // Log panel + if (hasLogs) ...[ + const Divider(height: 1, color: AppColors.border), + _logPanel(), + ], + ], + ); + } + + Widget _containerTile(ContainerEntry c) { + final running = _isRunning(c); + final loading = _actionLoading[c.id] == true; + final isLogTarget = _logContainer?.id == c.id; + + return Container( + decoration: isLogTarget + ? BoxDecoration( + border: Border( + left: BorderSide(color: AppColors.accent, width: 3), + ), + ) + : null, + child: ListTile( + dense: true, + title: Text(c.name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + subtitle: Text('${c.image} • ${c.status}', + style: + const TextStyle(fontSize: 11, color: AppColors.textSecondary)), + trailing: loading + ? const SizedBox( + width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + : _actionButtons(c, running), + ), + ); + } + + Widget _actionButtons(ContainerEntry c, bool running) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + if (running) + _iconBtn(Icons.stop_circle_outlined, 'Stop', + () => _runAction(c, () => widget.service.stopContainer(widget.host, c.id))), + if (running) + _iconBtn(Icons.replay, 'Restart', + () => _runAction(c, () => widget.service.restartContainer(widget.host, c.id))), + if (!running) + _iconBtn(Icons.play_circle_outline, 'Start', + () => _runAction(c, () => widget.service.startContainer(widget.host, c.id))), + _iconBtn(Icons.terminal, 'Exec', () => _execContainer(c)), + if (running) + _iconBtn( + Icons.article_outlined, + 'Logs', + () => _logContainer?.id == c.id ? _closeLogs() : _openLogs(c), + active: _logContainer?.id == c.id, + ), + ]); + } + + Widget _iconBtn(IconData icon, String tooltip, VoidCallback onTap, + {bool active = false}) { + return IconButton( + tooltip: tooltip, + icon: Icon(icon, size: 16), + color: active ? AppColors.accent : null, + onPressed: onTap, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + padding: EdgeInsets.zero, + ); + } + + Widget _logPanel() { + return Expanded( + flex: 2, + child: Column( + children: [ + // Log panel header + Container( + color: AppColors.card, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row(children: [ + const Icon(Icons.article_outlined, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Expanded( + child: Text('Logs: ${_logContainer!.name}', + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + overflow: TextOverflow.ellipsis), + ), + TextButton( + onPressed: () => setState(() => _logLines.clear()), + child: const Text('Clear', style: TextStyle(fontSize: 11)), + ), + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: _closeLogs, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + padding: EdgeInsets.zero, + ), + ]), + ), + // Log lines + Expanded( + child: ListView.builder( + controller: _logScroll, + padding: const EdgeInsets.all(8), + itemCount: _logLines.length, + itemBuilder: (_, i) => Text( + _logLines[i], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: AppColors.textPrimary), + ), + ), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 2: Create widget test** + +Create `app/test/widgets/docker_panel_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/container_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/docker_panel.dart'; + +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + ({String stdout, String stderr, int exitCode}) Function(String cmd)? execStub; + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String cmd, {String? auditSource}) async => + execStub?.call(cmd) ?? (stdout: '', stderr: '', exitCode: 0); + + @override + Stream execStream(Host host, String cmd, {String? auditSource}) => + const Stream.empty(); +} + +// DockerPanel reaches into SessionProvider only inside the Exec button +// callback (context.read()); these tests never tap Exec, so +// no provider ancestor is needed — a bare MaterialApp suffices. +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + final host = Host(label: 'h', host: 'srv', port: 22, username: 'u'); + + testWidgets('shows running containers with Stop and Logs buttons', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => ( + stdout: 'abc123|web|nginx:latest|Up 2 hours', + stderr: '', + exitCode: 0, + ); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + expect(find.text('web'), findsOneWidget); + expect(find.byTooltip('Stop'), findsOneWidget); + expect(find.byTooltip('Logs'), findsOneWidget); + expect(find.byTooltip('Start'), findsNothing); + }); + + testWidgets('shows Start button for stopped container', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => ( + stdout: 'abc123|worker|myimage:1.0|Exited (0) 5 minutes ago', + stderr: '', + exitCode: 0, + ); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + expect(find.byTooltip('Start'), findsOneWidget); + expect(find.byTooltip('Stop'), findsNothing); + }); + + testWidgets('shows No running containers for empty list', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(DockerPanel(host: host, service: svc))); + await tester.pump(); + + expect(find.text('No running containers.'), findsOneWidget); + }); +} +``` + +- [ ] **Step 3: Run widget tests** + +```bash +cd app && flutter test test/widgets/docker_panel_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 4: Commit** + +```bash +git add app/lib/widgets/docker_panel.dart \ + app/test/widgets/docker_panel_test.dart +git commit -m "feat(docker): DockerPanel widget with logs and lifecycle actions" +``` + +--- + +## Task 5: ComposePanel widget + +**Files:** +- Create: `app/lib/widgets/compose_panel.dart` +- Create: `app/test/widgets/compose_panel_test.dart` + +**Interfaces:** +- Consumes: `Host`, `ContainerService` (Tasks 1+3), `ComposeStack`, `ComposeService` +- Produces: `ComposePanel(host: Host, service: ContainerService)` widget + +- [ ] **Step 1: Create `compose_panel.dart`** + +Create `app/lib/widgets/compose_panel.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/container_entry.dart'; +import '../models/host.dart'; +import '../services/container_service.dart'; +import '../theme/app_theme.dart'; + +class ComposePanel extends StatefulWidget { + const ComposePanel({super.key, required this.host, required this.service}); + + final Host host; + final ContainerService service; + + @override + State createState() => _ComposePanelState(); +} + +class _ComposePanelState extends State { + List _stacks = []; + bool _loadingStacks = false; + String? _stacksError; + + ComposeStack? _selectedStack; + List _services = []; + bool _loadingServices = false; + + // Log panel + ComposeService? _logService; + StreamSubscription? _logSub; + final List _logLines = []; + final ScrollController _logScroll = ScrollController(); + bool _autoScroll = true; + + // Per-action loading + final Map _actionLoading = {}; + + // Manual path input + final TextEditingController _manualCtrl = TextEditingController(); + bool _showManualInput = false; + + @override + void initState() { + super.initState(); + _logScroll.addListener(() { + final pos = _logScroll.position; + _autoScroll = pos.pixels >= pos.maxScrollExtent - 8; + }); + _loadStacks(); + } + + @override + void dispose() { + _logSub?.cancel(); + _logScroll.dispose(); + _manualCtrl.dispose(); + super.dispose(); + } + + void _scrollToBottom() { + if (!_autoScroll) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_logScroll.hasClients) { + _logScroll.jumpTo(_logScroll.position.maxScrollExtent); + } + }); + } + + Future _loadStacks() async { + setState(() { + _loadingStacks = true; + _stacksError = null; + }); + try { + _stacks = await widget.service.discoverComposeStacks(widget.host); + } catch (e) { + _stacksError = e.toString(); + } finally { + if (mounted) setState(() => _loadingStacks = false); + } + } + + Future _selectStack(ComposeStack stack) async { + if (_selectedStack?.projectDir == stack.projectDir) { + setState(() { + _selectedStack = null; + _services = []; + }); + return; + } + setState(() { + _selectedStack = stack; + _services = []; + _loadingServices = true; + }); + try { + final svcs = await widget.service.listComposeServices(widget.host, stack); + if (mounted) setState(() => _services = svcs); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString()), backgroundColor: Colors.red.shade800), + ); + } + } finally { + if (mounted) setState(() => _loadingServices = false); + } + } + + Future _stackAction( + ComposeStack stack, String key, Future Function() action) async { + setState(() => _actionLoading[key] = true); + try { + await action(); + await _loadStacks(); + if (_selectedStack?.projectDir == stack.projectDir) { + final svcs = await widget.service.listComposeServices(widget.host, stack); + if (mounted) setState(() => _services = svcs); + } + } catch (e) { + if (mounted) { + final msg = e.toString().length > 200 + ? '${e.toString().substring(0, 200)}…' + : e.toString(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg), backgroundColor: Colors.red.shade800), + ); + } + } finally { + if (mounted) setState(() => _actionLoading.remove(key)); + } + } + + void _openServiceLogs(ComposeService svc) { + final stack = _selectedStack; + if (stack == null) return; + _logSub?.cancel(); + setState(() { + _logService = svc; + _logLines.clear(); + _autoScroll = true; + }); + _logSub = widget.service + .streamComposeServiceLogs(widget.host, stack, svc.name) + .listen((line) { + if (mounted) { + setState(() { + _logLines.add(line); + if (_logLines.length > 2000) { + _logLines.removeRange(0, _logLines.length - 2000); + } + }); + _scrollToBottom(); + } + }, onDone: () { + if (mounted) setState(() => _logLines.add('— connection closed —')); + }, onError: (e) { + if (mounted) setState(() => _logLines.add('— error: $e —')); + }); + } + + void _closeLogs() { + _logSub?.cancel(); + _logSub = null; + setState(() { + _logService = null; + _logLines.clear(); + }); + } + + Future _addManualPath() async { + final path = _manualCtrl.text.trim(); + if (path.isEmpty) return; + setState(() => _actionLoading['manual'] = true); + try { + final r = await widget.service.ssh.exec( + widget.host, + "docker compose -f '$path' config --services 2>&1", + auditSource: 'devops'); + if (r.exitCode != 0) throw Exception('Not a valid Compose file: $path'); + final dir = path.contains('/') + ? path.substring(0, path.lastIndexOf('/')) + : '.'; + final name = dir.substring(dir.lastIndexOf('/') + 1); + final stack = ComposeStack(name: name, projectDir: dir, status: 'unknown'); + setState(() { + if (!_stacks.any((s) => s.projectDir == dir)) { + _stacks = [..._stacks, stack]; + } + _showManualInput = false; + _manualCtrl.clear(); + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString()), backgroundColor: Colors.red.shade800), + ); + } + } finally { + if (mounted) setState(() => _actionLoading.remove('manual')); + } + } + + @override + Widget build(BuildContext context) { + final hasLogs = _logService != null; + return Column(children: [ + // Toolbar + Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 4), + child: Row(children: [ + const Text('Compose Stacks', + style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), + const Spacer(), + IconButton( + tooltip: 'Add path manually', + icon: const Icon(Icons.add, size: 16), + onPressed: () => + setState(() => _showManualInput = !_showManualInput), + ), + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh, size: 16), + onPressed: _loadStacks, + ), + ]), + ), + // Manual path input + if (_showManualInput) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(children: [ + Expanded( + child: TextField( + controller: _manualCtrl, + style: const TextStyle(fontSize: 13), + decoration: const InputDecoration( + hintText: '/path/to/docker-compose.yml', + isDense: true, + contentPadding: + EdgeInsets.symmetric(horizontal: 8, vertical: 6), + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _addManualPath(), + ), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: _actionLoading['manual'] == true ? null : _addManualPath, + child: const Text('Add'), + ), + ]), + ), + // Stack list + Expanded( + flex: _selectedStack != null ? 1 : 3, + child: _buildStackList(), + ), + // Service list + if (_selectedStack != null) ...[ + const Divider(height: 1, color: AppColors.border), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row(children: [ + Text('Services in ${_selectedStack!.name}', + style: const TextStyle( + fontSize: 11, color: AppColors.textSecondary)), + ]), + ), + Expanded( + flex: 1, + child: _loadingServices + ? const Center(child: CircularProgressIndicator()) + : _buildServiceList(), + ), + ], + // Log panel + if (hasLogs) ...[ + const Divider(height: 1, color: AppColors.border), + _buildLogPanel(), + ], + ]); + } + + Widget _buildStackList() { + if (_loadingStacks) return const Center(child: CircularProgressIndicator()); + if (_stacksError != null) { + return Center(child: Text(_stacksError!, textAlign: TextAlign.center)); + } + if (_stacks.isEmpty) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.folder_open, size: 36, color: AppColors.textTertiary), + const SizedBox(height: 8), + const Text('No Compose stacks found.'), + const SizedBox(height: 4), + const Text('Tap + to add a path manually.', + style: TextStyle(fontSize: 11, color: AppColors.textSecondary)), + ]), + ); + } + return ListView.separated( + itemCount: _stacks.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: AppColors.border), + itemBuilder: (_, i) => _stackTile(_stacks[i]), + ); + } + + Widget _stackTile(ComposeStack stack) { + final selected = _selectedStack?.projectDir == stack.projectDir; + final upKey = 'up_${stack.projectDir}'; + final downKey = 'down_${stack.projectDir}'; + return ListTile( + dense: true, + selected: selected, + selectedTileColor: AppColors.accent.withValues(alpha: 0.08), + title: Text(stack.name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + subtitle: Text('${stack.status} • ${stack.projectDir}', + style: + const TextStyle(fontSize: 11, color: AppColors.textSecondary), + overflow: TextOverflow.ellipsis), + onTap: () => _selectStack(stack), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (_actionLoading[upKey] == true) + const SizedBox( + width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + else + TextButton( + onPressed: () => _stackAction( + stack, upKey, () => widget.service.composeUp(widget.host, stack)), + child: const Text('Up', style: TextStyle(fontSize: 12)), + ), + if (_actionLoading[downKey] == true) + const SizedBox( + width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + else + TextButton( + onPressed: () => _stackAction( + stack, downKey, () => widget.service.composeDown(widget.host, stack)), + child: const Text('Down', style: TextStyle(fontSize: 12)), + ), + ]), + ); + } + + Widget _buildServiceList() { + if (_services.isEmpty) { + return const Center(child: Text('No services found.')); + } + return ListView.separated( + itemCount: _services.length, + separatorBuilder: (_, _) => + const Divider(height: 1, color: AppColors.border), + itemBuilder: (_, i) => _serviceTile(_services[i]), + ); + } + + Widget _serviceTile(ComposeService svc) { + final stack = _selectedStack!; + final running = svc.status == 'running'; + final startKey = 'svcstart_${stack.projectDir}_${svc.name}'; + final stopKey = 'svcstop_${stack.projectDir}_${svc.name}'; + final isLogTarget = _logService?.name == svc.name; + + return ListTile( + dense: true, + title: Text(svc.name, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + subtitle: Text( + '${svc.status} • ${svc.image} • ${svc.replicas} replica${svc.replicas == 1 ? '' : 's'}', + style: + const TextStyle(fontSize: 11, color: AppColors.textSecondary)), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + if (running && _actionLoading[stopKey] != true) + IconButton( + tooltip: 'Stop service', + icon: const Icon(Icons.stop_circle_outlined, size: 16), + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + onPressed: () => _stackAction( + stack, stopKey, + () => widget.service.stopComposeService(widget.host, stack, svc.name)), + ), + if (!running && _actionLoading[startKey] != true) + IconButton( + tooltip: 'Start service', + icon: const Icon(Icons.play_circle_outline, size: 16), + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + onPressed: () => _stackAction( + stack, startKey, + () => widget.service.startComposeService(widget.host, stack, svc.name)), + ), + IconButton( + tooltip: 'Logs', + icon: const Icon(Icons.article_outlined, size: 16), + color: isLogTarget ? AppColors.accent : null, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + onPressed: () => + isLogTarget ? _closeLogs() : _openServiceLogs(svc), + ), + ]), + ); + } + + Widget _buildLogPanel() { + return Expanded( + flex: 2, + child: Column(children: [ + Container( + color: AppColors.card, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row(children: [ + const Icon(Icons.article_outlined, + size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Expanded( + child: Text('Logs: ${_logService!.name}', + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondary), + overflow: TextOverflow.ellipsis), + ), + TextButton( + onPressed: () => setState(() => _logLines.clear()), + child: const Text('Clear', style: TextStyle(fontSize: 11)), + ), + IconButton( + icon: const Icon(Icons.close, size: 14), + onPressed: _closeLogs, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + padding: EdgeInsets.zero, + ), + ]), + ), + Expanded( + child: ListView.builder( + controller: _logScroll, + padding: const EdgeInsets.all(8), + itemCount: _logLines.length, + itemBuilder: (_, i) => Text(_logLines[i], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: AppColors.textPrimary)), + ), + ), + ]), + ); + } +} +``` + +- [ ] **Step 2: Create widget test** + +Create `app/test/widgets/compose_panel_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/models/container_entry.dart'; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/container_service.dart'; +import 'package:yourssh/services/ssh_service.dart'; +import 'package:yourssh/services/storage_service.dart'; +import 'package:yourssh/widgets/compose_panel.dart'; + +class _FakeSshService extends SshService { + _FakeSshService() : super(StorageService()); + + ({String stdout, String stderr, int exitCode}) Function(String cmd)? execStub; + + @override + Future<({String stdout, String stderr, int exitCode})> exec( + Host host, String cmd, {String? auditSource}) async => + execStub?.call(cmd) ?? (stdout: '', stderr: '', exitCode: 0); + + @override + Stream execStream(Host host, String cmd, {String? auditSource}) => + const Stream.empty(); +} + +Widget _wrap(Widget child) => + MaterialApp(home: Scaffold(body: child)); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => SharedPreferences.setMockInitialValues({})); + + final host = Host(label: 'h', host: 'srv', port: 22, username: 'u'); + + testWidgets('shows No Compose stacks found when discovery returns empty', (tester) async { + final fake = _FakeSshService(); + // Both docker compose ls and find return empty + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + expect(find.text('No Compose stacks found.'), findsOneWidget); + }); + + testWidgets('shows stack name when discovery returns a result', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (cmd) { + if (cmd.contains('docker compose ls')) { + return ( + stdout: '[{"Name":"myapp","Status":"running(2)","ConfigFiles":"/opt/myapp/compose.yml"}]', + stderr: '', + exitCode: 0, + ); + } + return (stdout: '', stderr: '', exitCode: 0); + }; + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + expect(find.text('myapp'), findsOneWidget); + expect(find.text('Up'), findsOneWidget); + expect(find.text('Down'), findsOneWidget); + }); + + testWidgets('shows + button for manual path input', (tester) async { + final fake = _FakeSshService(); + fake.execStub = (_) => (stdout: '', stderr: '', exitCode: 0); + final svc = ContainerService(fake); + await tester.pumpWidget(_wrap(ComposePanel(host: host, service: svc))); + await tester.pump(); + + await tester.tap(find.byTooltip('Add path manually')); + await tester.pump(); + + expect(find.byType(TextField), findsOneWidget); + expect(find.text('Add'), findsOneWidget); + }); +} +``` + +- [ ] **Step 3: Run widget tests** + +```bash +cd app && flutter test test/widgets/compose_panel_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 4: Commit** + +```bash +git add app/lib/widgets/compose_panel.dart \ + app/test/widgets/compose_panel_test.dart +git commit -m "feat(docker): ComposePanel widget with stack discovery and service management" +``` + +--- + +## Task 6: Wire ContainersScreen — add Compose tab, use DockerPanel + +**Files:** +- Modify: `app/lib/widgets/containers_screen.dart` + +**Interfaces:** +- Consumes: `DockerPanel` (Task 4), `ComposePanel` (Task 5) +- No new exports — internal wiring only + +- [ ] **Step 1: Update `containers_screen.dart`** + +Key changes vs. the existing file: +1. Add `import` for `docker_panel.dart` and `compose_panel.dart`. +2. `enum _Tab { docker, kubernetes }` → `enum _Tab { docker, compose, kubernetes }`. +3. **Keep** the existing screen-level runtime scan (`_runtimes`, manual "Scan", `_HintCard` install/permission gating) — it is what gives the **Kubernetes** tab its "kubectl not installed" hint. Do NOT move detection into `DockerPanel` (that would silently drop the kubectl hint). +4. `_refresh()` now ONLY scans runtimes — it no longer lists containers (DockerPanel does that itself on init). Drop the `_containers` field, `_dockerList()`, and `_execContainer()` from the screen. +5. Compose and Docker tabs both gate on the **docker** runtime (`_availabilityFor` maps `kubernetes → kubectl`, everything else → `docker`). +6. When the runtime check passes, render `DockerPanel` / `ComposePanel` / `KubernetesPanel`. + +`_CenterHint` and `_HintCard` are unchanged from the current file — keep them. Full updated file: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../models/container_entry.dart'; +import '../models/host.dart'; +import 'kubernetes_panel.dart'; +import 'docker_panel.dart'; +import 'compose_panel.dart'; +import '../providers/session_provider.dart'; +import '../services/container_service.dart'; +import '../services/ssh_service.dart'; +import '../theme/app_theme.dart'; + +class ContainersScreen extends StatefulWidget { + const ContainersScreen({super.key, this.onOpenBrowser}); + final void Function(String url)? onOpenBrowser; + + @override + State createState() => _ContainersScreenState(); +} + +enum _Tab { docker, compose, kubernetes } + +class _ContainersScreenState extends State { + ContainerService? _service; + String? _sessionId; + _Tab _tab = _Tab.docker; + + RuntimeStatus? _runtimes; + bool _loading = false; + + ContainerService _ensureService() { + _service ??= ContainerService(context.read()); + return _service!; + } + + @override + Widget build(BuildContext context) { + final sessions = context.watch().sshSessions; + if (sessions.isEmpty) { + return const _CenterHint( + icon: Icons.terminal, + message: 'Open an SSH session first, then come back to browse containers.', + ); + } + _sessionId ??= sessions.first.id; + final selected = sessions.firstWhere( + (s) => s.id == _sessionId, + orElse: () => sessions.first, + ); + _sessionId = selected.id; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: DropdownButton( + value: _sessionId, + isExpanded: true, + items: [ + for (final s in sessions) + DropdownMenuItem(value: s.id, child: Text(s.title)), + ], + onChanged: (v) => setState(() { + _sessionId = v; + _runtimes = null; + }), + ), + ), + const SizedBox(width: 12), + IconButton( + tooltip: 'Rescan runtimes', + icon: const Icon(Icons.refresh), + onPressed: _loading ? null : _refresh, + ), + ], + ), + const SizedBox(height: 8), + Row(children: [ + _tabButton(_Tab.docker, 'Docker'), + const SizedBox(width: 8), + _tabButton(_Tab.compose, 'Compose'), + const SizedBox(width: 8), + _tabButton(_Tab.kubernetes, 'Kubernetes'), + ]), + const SizedBox(height: 8), + Expanded(child: _body()), + ], + ), + ); + } + + Widget _tabButton(_Tab tab, String label) { + final active = _tab == tab; + return ChoiceChip( + label: Text(label), + selected: active, + onSelected: (_) => setState(() => _tab = tab), + ); + } + + Widget _body() { + if (_loading) return const Center(child: CircularProgressIndicator()); + final host = _hostForSelected(); + if (host == null) { + return const _CenterHint(icon: Icons.link_off, message: 'Session not found.'); + } + final runtimes = _runtimes; + if (runtimes == null) { + return _CenterHint( + icon: Icons.search, + message: 'Tap refresh to scan for Docker / Kubernetes.', + actionLabel: 'Scan', + onAction: _refresh, + ); + } + + final avail = _availabilityFor(runtimes); + // Docker and Compose both require the docker runtime; only Kubernetes + // uses kubectl. + final runtimeName = _tab == _Tab.kubernetes ? 'kubectl' : 'docker'; + + if (avail == RuntimeAvailability.notInstalled) { + return _HintCard( + title: '$runtimeName is not installed on this host', + command: ContainerService.installHint(runtimeName, host.detectedOs), + ); + } + if (avail == RuntimeAvailability.noPermission) { + return _HintCard( + title: 'No permission to use $runtimeName', + command: ContainerService.permissionHint(runtimeName), + ); + } + + // Runtime is available — the panels load their own data on init. Each is + // remounted (fresh initState) whenever the session changes, because + // switching sessions sets `_runtimes = null`, which unmounts the panel + // until the next scan. + final svc = _ensureService(); + switch (_tab) { + case _Tab.docker: + return DockerPanel(host: host, service: svc); + case _Tab.compose: + return ComposePanel(host: host, service: svc); + case _Tab.kubernetes: + return KubernetesPanel(host: host, onOpenBrowser: widget.onOpenBrowser); + } + } + + Future _refresh() async { + final host = _hostForSelected(); + if (host == null) return; + setState(() => _loading = true); + try { + _runtimes = await _ensureService().detectRuntimes(host); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + RuntimeAvailability _availabilityFor(RuntimeStatus r) => + _tab == _Tab.kubernetes ? r.kubectl : r.docker; + + Host? _hostForSelected() { + final id = _sessionId; + if (id == null) return null; + return context.read().hostForSession(id); + } +} + +class _CenterHint extends StatelessWidget { + final IconData icon; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + const _CenterHint({ + required this.icon, + required this.message, + this.actionLabel, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 40, color: AppColors.textTertiary), + const SizedBox(height: 12), + Text(message, textAlign: TextAlign.center), + if (actionLabel != null) ...[ + const SizedBox(height: 12), + FilledButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ); + } +} + +class _HintCard extends StatelessWidget { + final String title; + final String command; + const _HintCard({required this.title, required this.command}); + + @override + Widget build(BuildContext context) { + return Center( + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + SelectableText( + command, + style: const TextStyle(fontFamily: 'monospace'), + ), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.copy, size: 16), + label: const Text('Copy command'), + onPressed: () { + Clipboard.setData(ClipboardData(text: command)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied')), + ); + }, + ), + ], + ), + ), + ), + ); + } +} +``` + +> **DockerPanel is unchanged from Task 4** — it does NOT call `detectRuntimes`; the screen has already confirmed docker is available before rendering it. No `_HintCard` copy into `docker_panel.dart`, no `flutter/services.dart` import there. + +- [ ] **Step 2: Verify full test suite passes** + +```bash +cd app && flutter test test/services/container_service_docker_test.dart \ + test/services/container_service_compose_test.dart \ + test/widgets/docker_panel_test.dart \ + test/widgets/compose_panel_test.dart +``` + +Expected: `All tests passed!` + +- [ ] **Step 3: Analyze for errors** + +```bash +cd app && flutter analyze --no-fatal-infos 2>&1 | grep "error:" | head -20 +``` + +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add app/lib/widgets/containers_screen.dart +git commit -m "feat(docker): wire DockerPanel + ComposePanel into ContainersScreen" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- [x] Docker logs (`streamDockerLogs` + log panel in DockerPanel) — Task 2 + Task 4 +- [x] Stop/Start/Restart (`stopContainer`, `startContainer`, `restartContainer`) — Task 2 + Task 4 +- [x] Compose discovery (scan + `docker compose ls` + manual fallback) — Task 3 + Task 5 +- [x] List stacks + services — Task 3 + Task 5 +- [x] Up/Down stacks — Task 3 + Task 5 +- [x] Per-service logs — Task 3 + Task 5 +- [x] Start/stop service — Task 3 + Task 5 +- [x] `docker compose` v1 hint — `ComposePanel` surfaces discovery error (v1 parse fails gracefully; if `docker compose ls` returns non-zero, hint needed) +- [x] Tests for static parsers — Task 3 +- [x] 3-tab layout (Docker | Compose | Kubernetes) — Task 6 +- [x] Log line cap 2000 — both DockerPanel and ComposePanel +- [x] `auditSource: 'devops'` — all methods in ContainerService + +**Note on v1 hint:** `parseComposeLs` returns `[]` on any error silently. If Docker Compose v1 is installed, `docker compose ls` may not exist and returns non-zero — discovery will fall back to find-only, not show an error. This is intentional: the feature works in degraded mode. The v2 hint is only shown if `docker compose ps` fails on a selected stack. diff --git a/docs/superpowers/specs/2026-06-18-docker-panel-completion-design.md b/docs/superpowers/specs/2026-06-18-docker-panel-completion-design.md new file mode 100644 index 00000000..39021c13 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-docker-panel-completion-design.md @@ -0,0 +1,298 @@ +# Docker Panel Completion — Design Spec + +**Date:** 2026-06-18 +**Feature:** Docker logs, restart/stop, and Compose awareness for the Containers screen +**Priority:** P1 (Workflow & integrations) + +--- + +## Goal + +Complete the Docker half of the Containers screen. Currently the screen lists running containers and opens an exec shell. This spec adds: + +1. **Docker logs** — inline `docker logs -f` streaming panel (consistent with the existing K8s log panel in `KubernetesPanel`) +2. **Container lifecycle actions** — Stop / Start / Restart buttons per container +3. **Compose awareness** — discover stacks, list services with status, Up/Down stacks, Start/Stop services, per-service log streaming + +Kubernetes support (`KubernetesPanel`) is untouched. + +--- + +## Existing code baseline + +| File | What it does today | +|------|--------------------| +| `app/lib/models/container_entry.dart` | `ContainerEntry`, `PodEntry`, `RuntimeStatus`, `RuntimeAvailability`, `K8sForwardHandle` | +| `app/lib/services/container_service.dart` | `listDockerContainers`, `detectRuntimes`, `dockerExecCommand`, K8s methods, `streamLogs` (K8s), `startPodPortForward` | +| `app/lib/widgets/containers_screen.dart` | `ContainersScreen` — session picker, Docker/K8s toggle, `_dockerList()` (list + exec), delegates K8s to `KubernetesPanel` | +| `app/lib/widgets/kubernetes_panel.dart` | `KubernetesPanel` — context/namespace, pod list, inline log panel, port-forward | + +--- + +## Architecture + +### Tab layout + +`ContainersScreen` adds a `compose` tab to the existing enum: + +```dart +enum _Tab { docker, compose, kubernetes } +``` + +The Docker tab renders the new extracted `DockerPanel` widget. The Compose tab renders the new `ComposePanel` widget. `KubernetesPanel` is unchanged. + +### File map + +| File | Change | +|------|--------| +| `app/lib/models/container_entry.dart` | Add `ComposeStack`, `ComposeService` | +| `app/lib/services/container_service.dart` | Add 8 new methods (Docker actions + Compose) | +| `app/lib/widgets/containers_screen.dart` | Add `_Tab.compose`, render `DockerPanel` / `ComposePanel` | +| `app/lib/widgets/docker_panel.dart` | **New** — container list + log panel + lifecycle actions | +| `app/lib/widgets/compose_panel.dart` | **New** — stack discovery + service list + log panel | +| `app/test/services/container_service_docker_test.dart` | **New** — unit tests for Docker parse/action methods | +| `app/test/services/container_service_compose_test.dart` | **New** — unit tests for Compose parse/discovery methods | + +--- + +## Models + +Added to `container_entry.dart`: + +```dart +/// One Docker Compose project (from `docker compose ls` or file discovery). +class ComposeStack { + final String name; // project name + final String projectDir; // absolute path to the directory containing the compose file + final String status; // e.g. "running(3)", "exited(1)", "created" + + const ComposeStack({ + required this.name, + required this.projectDir, + required this.status, + }); +} + +/// One service within a Docker Compose stack (from `docker compose ps`). +class ComposeService { + final String name; + final String status; // e.g. "running", "exited", "created" + final String image; + final int replicas; // number of containers for this service + + const ComposeService({ + required this.name, + required this.status, + required this.image, + this.replicas = 1, + }); +} +``` + +--- + +## ContainerService — new methods + +All use `ssh.exec` / `ssh.execStream` with `auditSource: 'devops'`. All throw `Exception` on non-zero exit code with the trimmed stderr as the message. + +### Docker logs + lifecycle + +```dart +/// Streams stdout+stderr from `docker logs -f `. +Stream streamDockerLogs(Host host, String id, {int tail = 100}); + +/// `docker stop ` — throws on failure. +Future stopContainer(Host host, String id); + +/// `docker start ` — throws on failure. +Future startContainer(Host host, String id); + +/// `docker restart ` — throws on failure. +Future restartContainer(Host host, String id); +``` + +`streamDockerLogs` command: `docker logs -f --tail=$tail $id 2>&1` +(stderr merged into stdout so errors surface in the log panel, not silently dropped.) + +### Compose + +```dart +/// Discovers Compose stacks: runs `docker compose ls --format json` first, +/// then falls back to a `find` scan of common directories. Results are merged +/// and deduplicated by projectDir. +Future> discoverComposeStacks(Host host); + +/// `cd && docker compose ps --format json` for the given stack. +Future> listComposeServices(Host host, ComposeStack stack); + +/// `cd && docker compose up -d` +Future composeUp(Host host, ComposeStack stack); + +/// `cd && docker compose down` +Future composeDown(Host host, ComposeStack stack); + +/// `cd && docker compose start ` +Future startComposeService(Host host, ComposeStack stack, String service); + +/// `cd && docker compose stop ` +Future stopComposeService(Host host, ComposeStack stack, String service); + +/// `cd && docker compose logs -f --tail=$tail 2>&1` +Stream streamComposeServiceLogs( + Host host, ComposeStack stack, String service, {int tail = 100}); +``` + +All Compose commands `cd` into `stack.projectDir` (single-quoted) before running `docker compose`, which auto-discovers the `docker-compose.yml` / `compose.yml` in that directory. This avoids tracking the exact config-file path (`-f`) per stack — the project directory is enough, and it works identically for `ls`-discovered and `find`-discovered stacks. + +### Discovery internals + +`discoverComposeStacks` runs two commands concurrently via `Future.wait`: + +1. `docker compose ls --format json 2>/dev/null` — returns active/known projects as JSON array `[{Name, Status, ConfigFiles}]`. Requires Docker Compose v2. On parse failure (v1 / missing) returns empty list, not an error. +2. `find ~ /opt /srv /home -maxdepth 3 \( -name "docker-compose.yml" -o -name "compose.yml" \) 2>/dev/null` — collects paths, derives `projectDir = dirname(path)`, `name = basename(projectDir)`, `status = "unknown"`. + +Merge: entries from step 1 take precedence; step 2 entries with the same `projectDir` are skipped. + +### Static parse functions (testable without SSH) + +```dart +static List parseComposeLs(String json); +static List parseComposeFindOutput(String stdout); +static List parseComposePs(String json); +``` + +All are `static` methods — no SSH dependency, fully unit-testable. + +--- + +## DockerPanel widget + +New file `app/lib/widgets/docker_panel.dart`. Replaces `_dockerList()` in `ContainersScreen`. + +**State:** +``` +List _containers +ContainerEntry? _logContainer // which container's logs are shown +StreamSubscription? _logSub +List _logLines // capped at 2000 lines (drop oldest) +ScrollController _logScroll +Map _actionLoading // containerId → loading +String? _actionError +``` + +**Layout:** + +``` +┌─────────────────────────────────┐ +│ Container list (scrollable) │ ← always visible +│ [name] [image · status] │ +│ [Stop] [Restart] [Exec] [Logs] │ +├─────────────────────────────────┤ ← visible only when _logContainer != null +│ Logs: [Clear] [×]│ +│ (streaming log lines) │ +│ (auto-scroll unless user paged) │ +└─────────────────────────────────┘ +``` + +**Action button visibility:** +- Container is running (`status` starts with "Up") → show **Stop**, **Restart**, **Exec**, **Logs** +- Container is stopped (`status` starts with "Exited" / "Created") → show **Start**, **Exec** (no Logs — container not running) +- One action in progress per container → that row's buttons disabled + +**Log panel behaviour:** +- Opening logs closes any previously open log subscription (`_logSub?.cancel()`) +- Auto-scroll: if the scroll is at the bottom, each new line scrolls down; if the user scrolled up, auto-scroll is paused until they scroll back to the bottom +- Lines capped at 2000; oldest dropped on overflow +- `×` button cancels subscription and hides panel + +**Refresh:** container list refreshes after any Stop/Start/Restart completes. + +--- + +## ComposePanel widget + +New file `app/lib/widgets/compose_panel.dart`. + +**State:** +``` +List _stacks +ComposeStack? _selectedStack +List _services +ComposeService? _logService +StreamSubscription? _logSub +List _logLines // capped at 2000 +ScrollController _logScroll +bool _loadingStacks, _loadingServices +String? _error +Map _actionLoading // key → loading +TextEditingController _manualPathCtrl +``` + +**Layout:** + +``` +┌───────────────────────────────────┐ +│ [Refresh] [+ Add path] │ +│ Stack list (scrollable) │ +│ [name] [status] [Up] [Down] │ +├───────────────────────────────────┤ ← visible when _selectedStack != null +│ Services in (scrollable) │ +│ [svc] [status] [Start/Stop] [Logs]│ +├───────────────────────────────────┤ ← visible when _logService != null +│ Logs: [Clear] [×] │ +│ (streaming log lines) │ +└───────────────────────────────────┘ +``` + +**Discovery flow:** +1. On mount / Refresh → call `discoverComposeStacks` +2. If no stacks found → show "No Compose stacks found" + a `+` to add a path manually +3. Manual path → call `docker compose -f config --services`; on success, synthesise a `ComposeStack` (projectDir = dirname of the path) and add to list + +The ComposePanel is only reached after the screen has confirmed the **docker** runtime is available (the Compose tab gates on `runtimes.docker`, same as the Docker tab). Compose v2 itself is not separately probed: if `docker compose` is missing/v1, `docker compose ls` returns non-zero and discovery degrades to the `find` scan only; any Up/Down/service action then fails with the daemon's own error surfaced in a SnackBar. The manual-path option remains available throughout. + +**Stack selection:** tapping a stack row expands service list (calls `listComposeServices`). Tapping again collapses. + +**Up/Down:** Up runs `composeUp`, Down runs `composeDown`. Both show a loading spinner on the row, then refresh the stack's status. A non-zero exit surfaces a `SnackBar` with the trimmed stderr (truncated at 200 chars). + +**Service Start/Stop:** same pattern — loading per-service, refresh service list on completion, error in SnackBar. + +**Log panel:** same auto-scroll / cap / cancel behaviour as `DockerPanel`. + +--- + +## Error handling + +| Scenario | Behaviour | +|----------|-----------| +| `docker` not installed | Existing screen-level `_HintCard` with install command (gates both Docker and Compose tabs) | +| `docker compose` not available (v1 / missing) | `docker compose ls` returns non-zero → discovery degrades to `find`-only; actions fail with the daemon error in a SnackBar (no dedicated hint card) | +| Stop/start/restart fails | `SnackBar` with error text; container list refreshed | +| Compose up/down fails | `SnackBar` with truncated stderr (200 chars) | +| Log stream drops | Log panel appends "— connection closed —" as a final line | +| `find` scan errors (missing dirs, permission) | Suppressed via `2>/dev/null`; an empty/failed scan falls through to the manual-path option | + +--- + +## Testing + +### `container_service_docker_test.dart` +- `parseDockerPs` already tested — no change +- `streamDockerLogs` command string is correct (`--tail`, `2>&1`) +- `stopContainer` / `startContainer` / `restartContainer`: mock `SshService.exec` returning exit 0 → no throw; exit 1 → throws with stderr + +### `container_service_compose_test.dart` +- `parseComposeLs` — valid JSON, empty array, malformed JSON (returns []) +- `parseComposeFindOutput` — paths, dedup, empty output +- `parseComposePs` — valid JSON, empty, unknown field (ignored) +- `discoverComposeStacks` dedup logic — same projectDir from both sources → only one entry + +--- + +## Out of scope + +- Docker volume / network management +- `docker pull` / image management +- Docker Compose v1 (`docker-compose` binary) — hint to upgrade, not supported +- Compose watch mode (`docker compose watch`) +- RDP/VNC proxy for containers diff --git a/docs/wiki/User-Guide-DevOps-Plugin.md b/docs/wiki/User-Guide-DevOps-Plugin.md index e52005ee..efececdd 100644 --- a/docs/wiki/User-Guide-DevOps-Plugin.md +++ b/docs/wiki/User-Guide-DevOps-Plugin.md @@ -4,21 +4,33 @@ The built-in DevOps hub adds infrastructure tooling on top of your SSH sessions. -## Containers (Docker / Kubernetes) +## Containers (Docker / Compose / Kubernetes) -List and exec into running containers on the active SSH session. +Manage containers and Compose stacks on the active SSH session. Pick a session, tap **Scan** to detect runtimes, then switch between the **Docker**, **Compose**, and **Kubernetes** tabs. If a runtime is missing the panel shows an install/permission hint with a copyable command. ### Docker -- Lists containers from `docker ps` -- Click **Exec** to open a shell in any container in a new terminal tab +- Lists all containers (`docker ps -a`), running and stopped +- Per-container lifecycle: **Stop** / **Restart** (running) or **Start** (stopped) +- **Exec** opens a shell in the container in a new terminal tab +- **Logs** opens an inline follow-mode viewer (`docker logs -f`) below the list — auto-scrolls while you stay at the bottom, detaches when you scroll up, **Clear** to reset, capped at 2000 lines + +### Compose + +- Discovers Compose v2 stacks by combining `docker compose ls` (active projects) with a `find` sweep of `~ /opt /srv /home`; active-project status wins when a stack appears in both +- **+** adds a Compose file by path manually (validated with `docker compose -f … config`) +- Per-stack **Up** (`up -d`) / **Down**; select a stack to list its services +- Per-service **Start** / **Stop**, replica count, and an inline follow-mode log viewer (`docker compose logs -f `) +- Docker Compose **v1** (`docker-compose`) is not supported — discovery degrades to find-only ### Kubernetes - Lists pods from `kubectl get pods -n ` - Namespace filter + **All namespaces** toggle - Click **Exec** to shell into any container in a pod -- If Docker or kubectl is missing, the panel shows install/permission hints +- `kubectl logs -f` and 1-click port-forward + +All container commands are recorded in the [Audit Log](User-Guide-Audit-Log) with source `devops`. ## Network Tools