diff --git a/.claude/skills/yourssh-screenshots/SKILL.md b/.claude/skills/yourssh-screenshots/SKILL.md new file mode 100644 index 00000000..eba679f8 --- /dev/null +++ b/.claude/skills/yourssh-screenshots/SKILL.md @@ -0,0 +1,96 @@ +--- +name: yourssh-screenshots +description: Use when the user wants to update, refresh, or add screenshots for the YourSSH app — e.g. "chụp screenshot", "cập nhật screenshots", "refresh screenshots", "add screenshots for new feature", or after a UI change. +--- + +# yourssh-screenshots + +Captures screenshots of every YourSSH feature screen using Flutter integration tests (render tree — no macOS Screen Recording permission needed). Saves PNGs to `screenshots//`, then updates README. + +## Quick Reference + +```bash +# Capture all general feature screenshots +cd app && flutter test integration_test/feature_screenshots_test.dart -d macos + +# Capture RDP-specific screenshots (requires Docker) +cd app && flutter test integration_test/rdp_screenshots_test.dart -d macos +``` + +## Folder Structure + +``` +screenshots/ + 01-terminal-ssh/ # Dashboard, host editor, SSH terminal + 02-sftp/ # Dual-panel SFTP + 03-port-forwarding/ # Port forward rules + 04-credentials-security/ # Keychain, known hosts + 05-settings/ # Settings sections + 06-plugins/ # Plugin manager + 07-rdp/ # RDP workspace (populated by rdp test) + 08-devops/ # DevOps hub + 10-audit-recording/ # Audit log, recording library + *.png # Legacy flat screenshots (keep for features needing live connections) +``` + +## How It Works + +`integration_test/feature_screenshots_test.dart`: +1. Backs up user's real host data +2. Seeds demo hosts + port forward rules into SharedPreferences +3. Launches `app.main()` via Flutter integration test harness +4. Navigates each sidebar section by tapping the nav label text +5. Captures frames via `RendererBinding.instance.renderViews.first` +6. Restores user data in `finally` + +`integration_test/rdp_screenshots_test.dart`: +- Requires `docker run -d --name yourssh-rdp-demo -p 3389:3389 scottyhardy/docker-remote-desktop:latest` +- Connects to a real xrdp container; captures fullscreen + TOFU dialog screenshots + +## Adding a New Screen + +1. Open `app/integration_test/feature_screenshots_test.dart` +2. Navigate to the screen with `_navTo(tester, 'Label')` or tap the relevant widget +3. Call `await _snap(tester, '$_gN/filename.png')` where `_gN` is the group constant +4. Add a new group constant if needed: `const _gN = '$_outDir/NN-group-name';` +5. Run the test to verify +6. Add the `` entry to README under the correct section + +## Updating README + +The `## Screenshots` section in `README.md` is organized by feature groups matching the folder structure. Each group uses an HTML `` with 2-column rows: + +```html +### Group Name +
+ + + + +
Screen Title
Screen Title
+``` + +After updating, verify all paths exist: +```bash +grep -o 'screenshots/[^"]*\.png' README.md | while read p; do + [ -f "$p" ] && echo "OK: $p" || echo "MISSING: $p" +done +``` + +## Key Implementation Details + +- **Storage keys**: `yourssh.hosts`, `yourssh.known_hosts`, `yourssh.port_forwards` (not bare `hosts`) +- **Nav tap**: `find.text('Port Forwarding')`, `find.text('Known Hosts')`, etc. — matches sidebar label text +- **`_snap` helper**: pumps 200ms before capture to let animations settle +- **Debounce update check**: set `last_update_check` to `DateTime.now().millisecondsSinceEpoch` to prevent update banner polluting shots +- **PortForward JSON**: stored as a flat list, `type` is the enum name (`local`, `remote`, `dynamic`) + +## Common Mistakes + +| Problem | Fix | +|---|---| +| `screencapture` fails | Use integration test — no Screen Recording permission needed | +| Wrong prefs key | Use `yourssh.hosts` not `hosts`; `yourssh.port_forwards` not `port_forwards` | +| Screenshot is blank/clipped | Increase pump delay in `_snap` or add explicit `_waitFor` before snap | +| Update banner appears | Set `last_update_check` to now in test setup | +| User data not restored | Wrap all test steps in `try { ... } finally { restore }` | diff --git a/CHANGELOG.md b/CHANGELOG.md index c22f79c8..84080c08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.34] — 2026-06-08 ### Added +- **Kubernetes panel** — `KubernetesPanel` widget inside the DevOps plugin: context switcher, streamed `kubectl logs -f` in a scrollable sheet, and 1-click port-forward (`kubectl port-forward`) via `ContainerService.execStream`; namespace filter + all-namespaces toggle +- **`onOpenBrowser` DevOps callback** — `DevOpsPluginConfig` gains an `onOpenBrowser` callback so the host app can handle in-app browser navigation from DevOps tools without a hard dependency on the WebTools plugin - **Keyword highlighting** — user-defined regex rules tint matching terminal output at paint time in the xterm fork; defaults ship with Error/Warning/Fail rules (red/yellow/cyan); toggle + rule list + add/edit dialog + color picker in Settings → Terminal and the terminal config side panel; rules persisted in `SettingsProvider` - **Server monitor panel** — per-host live dashboard (CPU / memory / disk / uptime / listening ports / firewall status) in a draggable bottom sheet; access from the host card hover button or right-click context menu; `SystemStatsService` polls every 5 s via a single compound SSH exec with sentinel markers; `FirewallStatusService` polls every 30 s and auto-detects ufw / iptables / nftables; requires an active SSH session - **Network discovery** — scan the local network for SSH/RDP hosts without typing an IP; `NetworkDiscoveryService` combines mDNS (`_ssh._tcp`, `_rdp._tcp`) and a configurable TCP port scan on the local subnet; results appear in a bottom sheet with one-tap **Add Host**; also reachable from a **Scan network** link in the Add Host panel @@ -27,6 +29,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Network discovery: silent error catches replaced with `debugPrint` logging; `_loadSubnets` now handles `NetworkInterface.list` failures gracefully; scan errors shown in the UI - `DiscoveredHost.merge()` now preserves source when merging two hosts from the same discovery method +### Performance +- Dashboard sort memoization — `HostsDashboard` no longer re-sorts the full host list on every keystroke; added an `identical`-based memo (`_memoSortedHosts`) and a set-based selection cleanup; O(n log n) per build → amortized O(1) for unchanged inputs +- External-edit watcher deduplication — `ExternalEditService._startWatcher` cancels any existing watcher for the same (host, remote path) before creating a new one, preventing duplicate poll timers when a file is re-opened +- K8s port-forward log-line accumulation — `ContainerService.startPodPortForward` listener early-returns after `Forwarding from` is matched instead of accumulating all kubectl output indefinitely + --- ## [0.1.33] — 2026-06-07 diff --git a/README.md b/README.md index 2f495dc8..8acd5436 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- YourSSH + YourSSH

YourSSH

License: MIT @@ -86,6 +86,7 @@ sudo dpkg -r yourssh - **Jump host / bastion proxy** — connect to internal servers via a bastion host; select any saved host as the jump hop in the host detail panel - **SSH agent forwarding** — per-host toggle (like `ssh -A`) to hop between servers with the keys on your local machine; serves your system agent, falling back to app-Keychain keys when no agent is running — private keys never leave your machine; a status line in the host panel and a per-session key icon show whether forwarding is ready, active, on Keychain fallback, or refused by the server - **Local shell** — spawn native macOS/Windows/Linux shell alongside SSH sessions +- **Keyword highlighting** — user-defined regex rules tint matching terminal output; ships with Error/Warning/Fail defaults (red/yellow/cyan); toggle, rule list, add/edit dialog, and per-rule color picker in Settings → Terminal and the terminal config side panel - **xterm-256color** terminal emulation with full PTY support ### File Management @@ -148,30 +149,70 @@ sudo dpkg -r yourssh ## Screenshots +### Terminal & SSH + - + + + + + +
Home — Host List
Hosts Dashboard
New Host Panel (SSH)
SSH Terminal with AI Assistant
Dashboard — RDP Host Badges
+ +### SFTP + + - - + +
SFTP File Browser
Plugins
Dual-Panel SFTP Browser
+ +### Port Forwarding + + - - + +
DevOps Hub — Network Tools
Web Tools — HTTP Client
Port Forward Rules
+ +### Credentials & Security + + - - + + + +
Snippets
Settings — Sync
SSH Keychain
Known Hosts
+ +### Settings + + + + + + - - - + + +
Connection & Terminal
Terminal Themes
Sync
P2P QR Sync — Export via QR
Session Recording & Playback
Terminal Sharing (Multiplayer)
Settings — Terminal Themes
Updates
+ +### Plugins + + + + + + + + +
Plugin Manager
DevOps Hub — Network Tools
Web Tools — HTTP Client
Snippets
@@ -179,23 +220,35 @@ sudo dpkg -r yourssh - + - + - + -
Dashboard — RDP Host Badges
Host Editor — RDP Form
Server Certificate — TOFU Dialog
Server Certificate — TOFU Dialog
RDP Workspace — Connected
Fullscreen with Hover Pill
Fullscreen with Hover Pill
Fullscreen — Clean View
Hover Reveal — Exit Bar
Hover Reveal — Exit Bar
Back to Windowed Mode
+### Audit Log & Recording + + + + + + + + + + +
Audit Log
Recording Library
Session Recording & Playback
Terminal Sharing (Multiplayer)
+ --- ## Tech Stack diff --git a/app/assets/app_icon.png b/app/assets/app_icon.png index 7bff3675..1309293e 100644 Binary files a/app/assets/app_icon.png and b/app/assets/app_icon.png differ diff --git a/app/assets/logo-readme.png b/app/assets/logo-readme.png new file mode 100644 index 00000000..87eead53 Binary files /dev/null and b/app/assets/logo-readme.png differ diff --git a/app/assets/logo.png b/app/assets/logo.png new file mode 100644 index 00000000..b3c3fb1e Binary files /dev/null and b/app/assets/logo.png differ diff --git a/app/integration_test/feature_screenshots_test.dart b/app/integration_test/feature_screenshots_test.dart new file mode 100644 index 00000000..629a4d8f --- /dev/null +++ b/app/integration_test/feature_screenshots_test.dart @@ -0,0 +1,290 @@ +// Captures screenshots of every major feature screen. +// +// Uses the Flutter render tree (no macOS Screen-Recording permission needed). +// Backs up user data before the run and restores it after. +// +// Run: +// cd app && flutter test integration_test/feature_screenshots_test.dart -d macos +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yourssh/main.dart' as app; +import 'package:yourssh/models/host.dart'; +import 'package:yourssh/services/storage_service.dart'; + +const _outDir = '/Users/thangnguyen/Projects/Personal/yourssh/screenshots'; + +// Screenshot folder groups +const _g1 = '$_outDir/01-terminal-ssh'; +const _g2 = '$_outDir/02-sftp'; +const _g3 = '$_outDir/03-port-forwarding'; +const _g4 = '$_outDir/04-credentials-security'; +const _g5 = '$_outDir/05-settings'; +const _g6 = '$_outDir/06-plugins'; +const _g9 = '$_outDir/09-recording'; + +Future _snap(WidgetTester tester, String path) async { + await tester.pump(const Duration(milliseconds: 200)); + final view = RendererBinding.instance.renderViews.first; + final layer = view.debugLayer! as OffsetLayer; + final image = await layer.toImage( + Offset.zero & view.size, + pixelRatio: view.flutterView.devicePixelRatio, + ); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + final file = File(path); + await file.parent.create(recursive: true); + await file.writeAsBytes(bytes!.buffer.asUint8List()); + // ignore: avoid_print + print('SNAP: $path'); +} + +Future _waitFor( + WidgetTester tester, + bool Function() cond, { + Duration timeout = const Duration(seconds: 20), + String what = 'condition', +}) async { + final end = DateTime.now().add(timeout); + while (!cond()) { + if (DateTime.now().isAfter(end)) throw TimeoutException('timed out: $what'); + await tester.pump(const Duration(milliseconds: 200)); + } +} + +/// Tap a sidebar nav item by its label text. +Future _navTo(WidgetTester tester, String label) async { + await tester.tap(find.text(label).first); + await tester.pump(const Duration(milliseconds: 400)); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('capture feature screenshots', (tester) async { + // Create output directories + for (final d in [_g1, _g2, _g3, _g4, _g5, _g6, _g9]) { + Directory(d).createSync(recursive: true); + } + + final prefs = await SharedPreferences.getInstance(); + final backupHosts = prefs.getString('yourssh.hosts'); + final backupKnown = prefs.getString('yourssh.known_hosts'); + final backupWorkspace = prefs.getString('workspace_snapshot'); + final backupUpdateCheck = prefs.getInt('last_update_check'); + final backupPortForwards = prefs.getString('yourssh.port_forwards'); + + final storage = StorageService(); + + // Seed demo hosts + final demoHosts = [ + Host( + id: 'demo-web-server', + label: 'Web Server', + host: 'web.example.com', + port: 22, + username: 'deploy', + authType: AuthType.privateKey, + ), + Host( + id: 'demo-db-server', + label: 'Database', + host: 'db.internal', + port: 22, + username: 'admin', + authType: AuthType.password, + ), + Host( + id: 'demo-bastion', + label: 'Bastion Host', + host: 'bastion.company.com', + port: 22, + username: 'ops', + authType: AuthType.agent, + ), + Host( + id: 'demo-dev', + label: 'Dev Machine', + host: '192.168.1.50', + port: 22, + username: 'dev', + authType: AuthType.privateKey, + ), + Host( + id: 'demo-rdp', + label: 'Windows Desktop', + host: '10.0.0.10', + port: 3389, + username: 'Administrator', + authType: AuthType.password, + protocol: HostProtocol.rdp, + rdpSecurity: RdpSecurityMode.nla, + ), + ]; + + // Seed port forwards + const portForwardsJson = '''[ + {"id":"pf-mysql","hostId":"demo-db-server","type":"local","localPort":3307,"remoteHost":"127.0.0.1","remotePort":3306,"label":"MySQL Tunnel","enabled":true}, + {"id":"pf-redis","hostId":"demo-web-server","type":"local","localPort":6379,"remoteHost":"127.0.0.1","remotePort":6379,"label":"Redis Tunnel","enabled":true}, + {"id":"pf-socks","hostId":"demo-bastion","type":"dynamic","localPort":1080,"remoteHost":"","remotePort":0,"label":"SOCKS5 Proxy","enabled":false} + ]'''; + + try { + await storage.saveHosts(demoHosts); + await storage.saveKnownHosts([]); + await prefs.remove('workspace_snapshot'); + await prefs.setInt('last_update_check', DateTime.now().millisecondsSinceEpoch); + await prefs.setString('yourssh.port_forwards', portForwardsJson); + + // ── Launch app ─────────────────────────────────────────────────────── + app.main(); + await tester.pump(const Duration(seconds: 2)); + await _waitFor( + tester, + () => find.text('Web Server').evaluate().isNotEmpty, + timeout: const Duration(seconds: 20), + what: 'dashboard with seeded hosts', + ); + + // ── 1. TERMINAL & SSH ──────────────────────────────────────────────── + + // 1a. Hosts dashboard + await _snap(tester, '$_g1/01-hosts-dashboard.png'); + + // 1b. NEW HOST panel open (SSH mode) + await tester.tap(find.text('NEW HOST').first); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g1/02-new-host-panel-ssh.png'); + + // 1c. Host editor with data filled in (edit the first SSH host) + await tester.tap(find.byIcon(Icons.close).first); + await tester.pump(const Duration(milliseconds: 300)); + // Long-press on "Web Server" card to open context menu, then Edit + await tester.longPress(find.text('Web Server').first); + await tester.pump(const Duration(milliseconds: 400)); + // Try tapping Edit in the context menu + final editFinder = find.text('Edit'); + if (editFinder.evaluate().isNotEmpty) { + await tester.tap(editFinder.first); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g1/03-host-editor-filled.png'); + // Close panel + await tester.tap(find.byIcon(Icons.close).first); + await tester.pump(const Duration(milliseconds: 300)); + } + + // ── 2. SFTP ────────────────────────────────────────────────────────── + await _navTo(tester, 'SFTP'); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g2/01-sftp-browser.png'); + + // ── 3. PORT FORWARDING ─────────────────────────────────────────────── + await _navTo(tester, 'Port Forwarding'); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g3/01-port-forward-rules.png'); + + // Open the "Add Rule" panel if available + final addRuleFinder = find.text('ADD RULE'); + if (addRuleFinder.evaluate().isNotEmpty) { + await tester.tap(addRuleFinder.first); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g3/02-add-rule-panel.png'); + final closeFinder = find.byIcon(Icons.close); + if (closeFinder.evaluate().isNotEmpty) { + await tester.tap(closeFinder.first); + await tester.pump(const Duration(milliseconds: 300)); + } + } + + // ── 4. CREDENTIALS & SECURITY ──────────────────────────────────────── + + // 4a. Keychain + await _navTo(tester, 'Keychain'); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g4/01-keychain.png'); + + // 4b. Known Hosts + await _navTo(tester, 'Known Hosts'); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g4/02-known-hosts.png'); + + // ── 5. SETTINGS ────────────────────────────────────────────────────── + await _navTo(tester, 'Settings'); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g5/01-settings-general.png'); + + // Scroll to show Terminal section + final scrollFinder = find.byType(SingleChildScrollView); + if (scrollFinder.evaluate().isNotEmpty) { + await tester.drag(scrollFinder.first, const Offset(0, -300)); + await tester.pump(const Duration(milliseconds: 300)); + await _snap(tester, '$_g5/02-settings-terminal.png'); + + // Scroll more to show Sync section + await tester.drag(scrollFinder.first, const Offset(0, -400)); + await tester.pump(const Duration(milliseconds: 300)); + await _snap(tester, '$_g5/03-settings-sync.png'); + + // Scroll more to show Updates section + await tester.drag(scrollFinder.first, const Offset(0, -400)); + await tester.pump(const Duration(milliseconds: 300)); + await _snap(tester, '$_g5/04-settings-updates.png'); + } + + // ── 6. PLUGINS ─────────────────────────────────────────────────────── + await _navTo(tester, 'Plugins'); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g6/01-plugins.png'); + + // ── 9. RECORDING ───────────────────────────────────────────────────── + await _navTo(tester, 'Recordings'); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_g9/01-recording-library.png'); + + // ── Audit Log ──────────────────────────────────────────────────────── + await _navTo(tester, 'Audit Log'); + await tester.pump(const Duration(milliseconds: 500)); + await _snap(tester, '$_outDir/audit-log.png'); + + // ── Back to Hosts — dashboard with RDP badge visible ───────────────── + await _navTo(tester, 'Hosts'); + await tester.pump(const Duration(milliseconds: 400)); + await _snap(tester, '$_g1/04-dashboard-with-rdp-badge.png'); + + } finally { + // Restore user data + if (backupHosts != null) { + await prefs.setString('yourssh.hosts', backupHosts); + } else { + await prefs.remove('yourssh.hosts'); + } + if (backupKnown != null) { + await prefs.setString('yourssh.known_hosts', backupKnown); + } else { + await prefs.remove('yourssh.known_hosts'); + } + if (backupWorkspace != null) { + await prefs.setString('workspace_snapshot', backupWorkspace); + } else { + await prefs.remove('workspace_snapshot'); + } + if (backupUpdateCheck != null) { + await prefs.setInt('last_update_check', backupUpdateCheck); + } else { + await prefs.remove('last_update_check'); + } + if (backupPortForwards != null) { + await prefs.setString('yourssh.port_forwards', backupPortForwards); + } else { + await prefs.remove('yourssh.port_forwards'); + } + } + }); +} diff --git a/app/lib/models/container_entry.dart b/app/lib/models/container_entry.dart index 7099f69a..e4a04c5c 100644 --- a/app/lib/models/container_entry.dart +++ b/app/lib/models/container_entry.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:io'; + /// One running Docker container (subset of `docker ps`). class ContainerEntry { final String id; @@ -38,3 +41,51 @@ class RuntimeStatus { const RuntimeStatus({required this.docker, required this.kubectl}); } + +/// Tracks a running `kubectl port-forward` process and the matching local +/// TCP server. Call [stop] to tear both down. +class K8sForwardHandle { + K8sForwardHandle({ + required this.pod, + required this.namespace, + required this.podPort, + required this.localPort, + required StreamSubscription kubectlSub, + required ServerSocket server, + required StreamSubscription serverSub, + required List closers, + }) // ignore: prefer_initializing_formals + : _kubectlSub = kubectlSub, // ignore: prefer_initializing_formals + _server = server, // ignore: prefer_initializing_formals + _serverSub = serverSub, // ignore: prefer_initializing_formals + _closers = closers; // ignore: prefer_initializing_formals + + final String pod; + final String namespace; + final int podPort; + final int localPort; + + final StreamSubscription _kubectlSub; + final ServerSocket _server; + final StreamSubscription _serverSub; + final List _closers; + + bool _stopped = false; + + Future stop() async { + if (_stopped) return; + _stopped = true; + try { + await _serverSub.cancel(); + } finally { + try { + await _server.close(); + } finally { + for (final c in List.of(_closers)) { + c(); + } + await _kubectlSub.cancel(); + } + } + } +} diff --git a/app/lib/services/container_service.dart b/app/lib/services/container_service.dart index 5c0d3bb8..4226d31b 100644 --- a/app/lib/services/container_service.dart +++ b/app/lib/services/container_service.dart @@ -1,6 +1,12 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:dartssh2/dartssh2.dart'; + import '../models/container_entry.dart'; -import 'ssh_service.dart'; import '../models/host.dart'; +import 'ssh_service.dart'; /// Lists Docker containers / Kubernetes pods on a remote host and detects /// which container runtimes are available. Parsing is done by stateless @@ -25,9 +31,12 @@ class ContainerService { Host host, { String namespace = 'default', bool allNamespaces = false, + String? context, }) async { final scope = allNamespaces ? '-A' : '-n $namespace'; - final r = await ssh.exec(host, 'kubectl get pods $scope', auditSource: 'devops'); + final ctxFlag = context != null ? ' --context=$context' : ''; + final r = await ssh.exec(host, 'kubectl get pods $scope$ctxFlag', + auditSource: 'devops'); if (r.exitCode != 0) { throw Exception(r.stderr.trim().isEmpty ? 'kubectl failed' : r.stderr.trim()); } @@ -42,6 +51,155 @@ class ContainerService { return parseContainerNames(r.stdout); } + // ── Contexts ────────────────────────────────────────── + + Future> listContexts(Host host) async { + final r = await ssh.exec(host, 'kubectl config get-contexts -o name', + auditSource: 'devops'); + if (r.exitCode != 0) return const []; + return parseContextNames(r.stdout); + } + + Future currentContext(Host host) async { + final r = await ssh.exec(host, 'kubectl config current-context', + auditSource: 'devops'); + if (r.exitCode != 0) return null; + final name = r.stdout.trim(); + return name.isEmpty ? null : name; + } + + static List parseContextNames(String stdout) { + return stdout + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + } + + // ── Log streaming ───────────────────────────────────── + + /// Streams stdout lines from `kubectl logs -f`. + /// Cancel the subscription to stop the stream and close the SSH channel. + Stream streamLogs( + Host host, + String pod, + String namespace, + String? context, { + String? container, + int tail = 100, + }) { + final ctxFlag = context != null ? ' --context=$context' : ''; + final cFlag = container != null ? ' -c $container' : ''; + final cmd = + 'kubectl logs -f $pod -n $namespace --tail=$tail$ctxFlag$cFlag'; + return ssh.execStream(host, cmd, auditSource: 'devops'); + } + + // ── Port forwarding ─────────────────────────────────── + + /// Starts `kubectl port-forward` on [host] and creates a local [ServerSocket] + /// on [localPort] that tunnels connections to the pod via SSH. + /// + /// Throws [TimeoutException] if kubectl does not print "Forwarding from" + /// within 10 seconds, or any [Exception] on kubectl error. + Future startPodPortForward( + Host host, + String pod, + String namespace, + String? context, + int podPort, + int localPort, + ) async { + final remotePfPort = 40000 + Random().nextInt(10000); + final ctxFlag = context != null ? ' --context=$context' : ''; + final cmd = 'kubectl port-forward --address 0.0.0.0 pod/$pod ' + '$remotePfPort:$podPort -n $namespace$ctxFlag'; + + final ready = Completer(); + final lines = []; + final logStream = ssh.execStream(host, cmd, auditSource: 'devops'); + + late StreamSubscription kubectlSub; + kubectlSub = logStream.listen( + (line) { + // Accumulate only until forwarding is confirmed. `lines` is read solely + // for the early-exit error message in onDone, so appending after `ready` + // completes would grow the buffer unbounded for the lifetime of the + // forward (kubectl logs one line per proxied connection). Keep draining + // the stream — just stop holding onto the lines. + if (ready.isCompleted) return; + lines.add(line); + if (line.contains('Forwarding from')) ready.complete(); + }, + onError: (e) { + if (!ready.isCompleted) ready.completeError(e); + }, + onDone: () { + if (!ready.isCompleted) { + ready.completeError( + Exception('kubectl exited: ${lines.join(' | ')}'), + ); + } + }, + ); + + try { + await ready.future.timeout(const Duration(seconds: 10)); + } catch (e) { + await kubectlSub.cancel(); + rethrow; + } + + final client = await ssh.ensureClient(host); + final server = + await ServerSocket.bind(InternetAddress.loopbackIPv4, localPort); + final closers = []; + + final serverSub = server.listen((socket) async { + try { + final channel = await client.forwardLocal('localhost', remotePfPort); + _pipeK8s(socket, channel, closers); + } catch (_) { + socket.destroy(); + } + }); + + return K8sForwardHandle( + pod: pod, + namespace: namespace, + podPort: podPort, + localPort: localPort, + kubectlSub: kubectlSub, + server: server, + serverSub: serverSub, + closers: closers, + ); + } + + static void _pipeK8s( + Socket local, SSHSocket remote, List closers) { + var done = false; + late final void Function() finish; + finish = () { + if (done) return; + done = true; + local.destroy(); + remote.destroy(); + closers.remove(finish); + }; + closers.add(finish); + unawaited(remote.stream + .cast>() + .pipe(local) + .catchError((_) {}) + .whenComplete(finish)); + unawaited(local + .cast>() + .pipe(remote.sink) + .catchError((_) {}) + .whenComplete(finish)); + } + static List parsePods( String stdout, { String namespace = 'default', diff --git a/app/lib/services/external_edit_service.dart b/app/lib/services/external_edit_service.dart index 2d2d6fd6..4060403f 100644 --- a/app/lib/services/external_edit_service.dart +++ b/app/lib/services/external_edit_service.dart @@ -94,6 +94,9 @@ class ExternalEditService { } void _startWatcher(Host host, SftpEntry entry, File localFile) { + // Re-opening the same remote file replaces its watcher rather than stacking + // a second timer that polls the same path for the lifetime of the screen. + _stopWatching(host.id, entry.path); final session = _WatchSession( host: host, entry: entry, @@ -104,6 +107,16 @@ class ExternalEditService { _sessions.add(session); } + void _stopWatching(String hostId, String remotePath) { + _sessions.removeWhere((s) { + if (s.host.id == hostId && s.entry.path == remotePath) { + s.timer?.cancel(); + return true; + } + return false; + }); + } + Future _launchWithApp(String filePath, String appPath) => launchFileWithApp(filePath, appPath); diff --git a/app/lib/services/ssh_service.dart b/app/lib/services/ssh_service.dart index 3a739f3e..9df83e69 100644 --- a/app/lib/services/ssh_service.dart +++ b/app/lib/services/ssh_service.dart @@ -1012,6 +1012,55 @@ class SshService { return execResult; } + /// Opens a persistent SSH exec channel and yields stdout lines. + /// Cancelling the returned stream's subscription closes the channel — + /// the remote process receives SIGHUP. + Stream execStream( + Host host, + String command, { + String? auditSource, + }) { + final controller = StreamController(); + _ensureClient(host).then((client) async { + final SSHSession session; + try { + session = await client.execute(command); + } catch (e) { + controller.addError(e); + unawaited(controller.close()); + return; + } + if (auditSource != null) { + audit?.record(AuditEvent.now( + type: AuditEventType.exec, + host: host, + command: command, + meta: {'source': auditSource}, + )); + } + final sub = session.stdout + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + controller.add, + onError: controller.addError, + onDone: () { + session.close(); + if (!controller.isClosed) unawaited(controller.close()); + }, + cancelOnError: false, + ); + controller.onCancel = () async { + await sub.cancel(); + session.close(); + }; + }).catchError((Object e, StackTrace st) { + controller.addError(e, st); + }); + return controller.stream; + } + // ── SFTP ─────────────────────────────────────────────── Future openSftp(Host host, {bool interactive = true}) async { diff --git a/app/lib/widgets/containers_screen.dart b/app/lib/widgets/containers_screen.dart index 5dfa1579..ed1aa78f 100644 --- a/app/lib/widgets/containers_screen.dart +++ b/app/lib/widgets/containers_screen.dart @@ -4,13 +4,15 @@ import 'package:provider/provider.dart'; import '../models/container_entry.dart'; import '../models/host.dart'; +import 'kubernetes_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}); + const ContainersScreen({super.key, this.onOpenBrowser}); + final void Function(String url)? onOpenBrowser; @override State createState() => _ContainersScreenState(); @@ -25,27 +27,10 @@ class _ContainersScreenState extends State { RuntimeStatus? _runtimes; List _containers = []; - List _pods = []; - String _namespace = 'default'; - bool _allNamespaces = false; bool _loading = false; String? _error; - late final TextEditingController _nsController; - - @override - void initState() { - super.initState(); - _nsController = TextEditingController(text: _namespace); - } - - @override - void dispose() { - _nsController.dispose(); - super.dispose(); - } - ContainerService _ensureService() { _service ??= ContainerService(context.read()); return _service!; @@ -103,7 +88,6 @@ class _ContainersScreenState extends State { const SizedBox(width: 8), _tabButton(_Tab.kubernetes, 'Kubernetes'), ]), - if (_tab == _Tab.kubernetes) _namespaceControls(), const SizedBox(height: 8), Expanded(child: _body()), ], @@ -119,43 +103,11 @@ class _ContainersScreenState extends State { onSelected: (_) => setState(() { _tab = tab; _containers = []; - _pods = []; _error = null; }), ); } - Widget _namespaceControls() { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Row(children: [ - SizedBox( - width: 200, - child: TextField( - enabled: !_allNamespaces, - decoration: const InputDecoration(labelText: 'Namespace', isDense: true), - controller: _nsController, - onSubmitted: (v) { - _namespace = v.trim().isEmpty ? 'default' : v.trim(); - _refresh(); - }, - ), - ), - const SizedBox(width: 12), - Row(children: [ - Checkbox( - value: _allNamespaces, - onChanged: (v) => setState(() { - _allNamespaces = v ?? false; - _refresh(); - }), - ), - const Text('All namespaces'), - ]), - ]), - ); - } - Widget _body() { if (_loading) return const Center(child: CircularProgressIndicator()); final host = _hostForSelected(); @@ -190,7 +142,8 @@ class _ContainersScreenState extends State { if (_error != null) { return _CenterHint(icon: Icons.error_outline, message: _error!); } - return _tab == _Tab.docker ? _dockerList() : _podList(); + if (_tab == _Tab.docker) return _dockerList(); + return KubernetesPanel(host: host, onOpenBrowser: widget.onOpenBrowser); } Widget _dockerList() { @@ -215,31 +168,8 @@ class _ContainersScreenState extends State { ); } - Widget _podList() { - if (_pods.isEmpty) { - return const _CenterHint(icon: Icons.inbox, message: 'No pods.'); - } - return ListView.separated( - itemCount: _pods.length, - separatorBuilder: (_, _) => const Divider(height: 1), - itemBuilder: (_, i) { - final p = _pods[i]; - return ListTile( - title: Text(p.name), - subtitle: Text('${p.namespace} • ${p.ready} • ${p.status}'), - trailing: FilledButton.icon( - icon: const Icon(Icons.terminal, size: 16), - label: const Text('Exec'), - onPressed: () => _execPod(p), - ), - ); - }, - ); - } - // ── Actions ─────────────────────────────────────────── Future _refresh() async { - _namespace = _nsController.text.trim().isEmpty ? 'default' : _nsController.text.trim(); final host = _hostForSelected(); if (host == null) return; setState(() { @@ -249,13 +179,9 @@ class _ContainersScreenState extends State { try { final svc = _ensureService(); _runtimes = await svc.detectRuntimes(host); - if (_availabilityFor(_runtimes!) == RuntimeAvailability.available) { - if (_tab == _Tab.docker) { - _containers = await svc.listDockerContainers(host); - } else { - _pods = await svc.listPods(host, - namespace: _namespace, allNamespaces: _allNamespaces); - } + if (_tab == _Tab.docker && + _availabilityFor(_runtimes!) == RuntimeAvailability.available) { + _containers = await svc.listDockerContainers(host); } } catch (e) { _error = e.toString(); @@ -275,38 +201,6 @@ class _ContainersScreenState extends State { ); } - Future _execPod(PodEntry p) async { - final host = _hostForSelected(); - if (host == null) return; - String? container; - final names = await _ensureService().podContainers(host, p.name, p.namespace); - if (names.length > 1 && mounted) { - container = await showDialog( - context: context, - builder: (_) => SimpleDialog( - title: const Text('Select container'), - children: [ - for (final n in names) - SimpleDialogOption( - child: Text(n), - onPressed: () => Navigator.pop(context, n), - ), - ], - ), - ); - if (container == null) return; // cancelled - } else if (names.length == 1) { - container = names.first; - } - if (!mounted) return; - final sessionProvider = context.read(); - await sessionProvider.connect( - host, - initialCommand: - ContainerService.kubectlExecCommand(p.name, p.namespace, container), - ); - } - RuntimeAvailability _availabilityFor(RuntimeStatus r) => _tab == _Tab.docker ? r.docker : r.kubectl; diff --git a/app/lib/widgets/host_detail_panel.dart b/app/lib/widgets/host_detail_panel.dart index 24da729c..376d8944 100644 --- a/app/lib/widgets/host_detail_panel.dart +++ b/app/lib/widgets/host_detail_panel.dart @@ -127,6 +127,17 @@ class _HostDetailPanelState extends State { for (final c in [_hostCtrl, _portCtrl, _usernameCtrl, _passwordCtrl]) { c.addListener(_clearTestResult); } + if (h != null && _authType == AuthType.password) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadExistingPassword(h.id)); + } + } + + Future _loadExistingPassword(String hostId) async { + if (!mounted) return; + final pw = await context.read().loadPassword(hostId); + if (mounted && pw != null && pw.isNotEmpty && _passwordCtrl.text.isEmpty) { + setState(() => _passwordCtrl.text = pw); + } } void _clearTestResult() { diff --git a/app/lib/widgets/hosts_dashboard.dart b/app/lib/widgets/hosts_dashboard.dart index c9ab0815..b217ae29 100644 --- a/app/lib/widgets/hosts_dashboard.dart +++ b/app/lib/widgets/hosts_dashboard.dart @@ -46,6 +46,35 @@ class _HostsDashboardState extends State { bool _selectionMode = false; final Set _selectedHostIds = {}; + // Memoized sort. Re-sorting the whole host list on every search keystroke is + // wasteful, and sorting is independent of the query (filtering preserves + // order). Cache the sorted list and reuse it until the host list or sort mode + // changes — invalidated by a cheap per-element identity compare, since + // HostProvider replaces Host objects (and the list) on every mutation. + List _sortedCache = const []; + List? _sortedInput; + HostSortMode? _sortedMode; + + List _memoSortedHosts(List hosts, HostSortMode mode) { + final prev = _sortedInput; + final unchanged = prev != null && + _sortedMode == mode && + prev.length == hosts.length && + _identicalElements(prev, hosts); + if (unchanged) return _sortedCache; + _sortedInput = hosts; + _sortedMode = mode; + _sortedCache = sortHosts(hosts, mode); + return _sortedCache; + } + + static bool _identicalElements(List a, List b) { + for (var i = 0; i < a.length; i++) { + if (!identical(a[i], b[i])) return false; + } + return true; + } + @override void dispose() { HardwareKeyboard.instance.removeHandler(_onSelectionKey); @@ -192,13 +221,20 @@ class _HostsDashboardState extends State { Widget build(BuildContext context) { final hostProvider = context.watch(); final hosts = hostProvider.allHosts; - _selectedHostIds.removeWhere((id) => !hosts.any((h) => h.id == id)); + // Prune stale selections in O(n) via a Set (only when a selection exists). + if (_selectedHostIds.isNotEmpty) { + final liveIds = {for (final h in hosts) h.id}; + _selectedHostIds.removeWhere((id) => !liveIds.contains(id)); + } final query = HostQuery.parse(_search); - final filtered = - query.isEmpty ? hosts : hosts.where(query.matches).toList(); final settings = context.watch(); final sortMode = HostSortMode.fromKey(settings.dashboardSort); - final sorted = sortHosts(filtered, sortMode); + // Sort the full list once (memoized), then filter the sorted result — the + // filter preserves order, so this avoids an O(n log n) re-sort per keystroke. + final sortedAll = _memoSortedHosts(hosts, sortMode); + final sorted = + query.isEmpty ? sortedAll : sortedAll.where(query.matches).toList(); + final filtered = sorted; final listView = settings.dashboardViewMode == 'list'; final facets = HostQuery.availableFacets(hosts); diff --git a/app/lib/widgets/kubernetes_panel.dart b/app/lib/widgets/kubernetes_panel.dart new file mode 100644 index 00000000..0cab6bcf --- /dev/null +++ b/app/lib/widgets/kubernetes_panel.dart @@ -0,0 +1,627 @@ +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 '../services/ssh_service.dart'; +import '../theme/app_theme.dart'; + +class KubernetesPanel extends StatefulWidget { + const KubernetesPanel({ + super.key, + required this.host, + this.onOpenBrowser, + }); + + final Host host; + + /// If non-null, "Open in Browser" buttons are shown for active port-forwards. + final void Function(String url)? onOpenBrowser; + + @override + State createState() => _KubernetesPanelState(); +} + +class _KubernetesPanelState extends State { + ContainerService? _svc; + + // ── Namespace / context ────────────────────────────── + String _namespace = 'default'; + bool _allNamespaces = false; + late TextEditingController _nsCtrl; + + String? _context; + List _contexts = []; + + // ── Pod list ───────────────────────────────────────── + List _pods = []; + bool _loading = false; + String? _error; + + // ── Log panel ──────────────────────────────────────── + PodEntry? _logPod; + String? _logContainer; + StreamSubscription? _logSub; + final List _logLines = []; + final ScrollController _logScroll = ScrollController(); + + // ── Port forwards ──────────────────────────────────── + final List _forwards = []; + + @override + void initState() { + super.initState(); + _nsCtrl = TextEditingController(text: _namespace); + _loadContexts(); + } + + @override + void didUpdateWidget(KubernetesPanel old) { + super.didUpdateWidget(old); + if (old.host.id != widget.host.id) { + _context = null; + _contexts = []; + _pods = []; + _error = null; + _closeLogPanel(); + for (final f in _forwards) { + f.stop(); + } + _forwards.clear(); + _loadContexts(); + } + } + + @override + void dispose() { + _nsCtrl.dispose(); + _logSub?.cancel(); + _logScroll.dispose(); + for (final f in _forwards) { + f.stop(); + } + super.dispose(); + } + + ContainerService _service() => + _svc ??= ContainerService(context.read()); + + Future _loadContexts() async { + final ctxs = await _service().listContexts(widget.host); + if (mounted) setState(() => _contexts = ctxs); + } + + Future _refresh() async { + _namespace = + _nsCtrl.text.trim().isEmpty ? 'default' : _nsCtrl.text.trim(); + setState(() { + _loading = true; + _error = null; + }); + try { + _pods = await _service().listPods( + widget.host, + namespace: _namespace, + allNamespaces: _allNamespaces, + context: _context, + ); + } catch (e) { + _error = e.toString(); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _headerRow(), + if (_forwards.isNotEmpty) _activeForwardsBar(), + Expanded(child: _body()), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: _logPod != null ? _logPanel() : const SizedBox.shrink(), + ), + ], + ); + } + + // ── Header ─────────────────────────────────────────── + + Widget _headerRow() { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (_contexts.isNotEmpty) _contextDropdown(), + SizedBox( + width: 180, + child: TextField( + enabled: !_allNamespaces, + decoration: const InputDecoration( + labelText: 'Namespace', isDense: true), + controller: _nsCtrl, + onSubmitted: (_) => _refresh(), + ), + ), + Row(mainAxisSize: MainAxisSize.min, children: [ + Checkbox( + value: _allNamespaces, + onChanged: (v) => setState(() { + _allNamespaces = v ?? false; + _refresh(); + }), + ), + const Text('All namespaces'), + ]), + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh), + onPressed: _loading ? null : _refresh, + ), + ], + ), + ); + } + + Widget _contextDropdown() { + return DropdownButton( + value: _context, + hint: const Text('Context'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('(default context)'), + ), + for (final c in _contexts) + DropdownMenuItem(value: c, child: Text(c)), + ], + onChanged: (v) => setState(() { + _context = v; + _refresh(); + }), + ); + } + + // ── Body ───────────────────────────────────────────── + + Widget _body() { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 40, color: AppColors.textTertiary), + const SizedBox(height: 12), + Text(_error!, textAlign: TextAlign.center), + ], + ), + ); + } + if (_pods.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox, size: 40, color: AppColors.textTertiary), + const SizedBox(height: 12), + const Text('No pods. Tap refresh to scan.'), + const SizedBox(height: 12), + FilledButton(onPressed: _refresh, child: const Text('Scan')), + ], + ), + ); + } + return _podList(); + } + + Widget _podList() { + return ListView.separated( + itemCount: _pods.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (_, i) { + final p = _pods[i]; + return ListTile( + title: Text(p.name), + subtitle: + Text('${p.namespace} • ${p.ready} • ${p.status}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.terminal, size: 18), + tooltip: 'Exec', + onPressed: () => _execPod(p), + ), + IconButton( + icon: const Icon(Icons.article_outlined, size: 18), + tooltip: 'Logs', + onPressed: () => _openLogs(p), + ), + IconButton( + icon: const Icon(Icons.swap_horiz, size: 18), + tooltip: 'Forward port', + onPressed: () => _showForwardDialog(p), + ), + ], + ), + ); + }, + ); + } + + // ── Exec ───────────────────────────────────────────── + + Future _execPod(PodEntry p) async { + String? container; + final names = + await _service().podContainers(widget.host, p.name, p.namespace); + if (names.length > 1 && mounted) { + container = await showDialog( + context: context, + builder: (_) => SimpleDialog( + title: const Text('Select container'), + children: [ + for (final n in names) + SimpleDialogOption( + child: Text(n), + onPressed: () => Navigator.pop(context, n), + ), + ], + ), + ); + if (container == null) return; + } else if (names.length == 1) { + container = names.first; + } + if (!mounted) return; + await context.read().connect( + widget.host, + initialCommand: ContainerService.kubectlExecCommand( + p.name, p.namespace, container), + ); + } + + // ── Log panel ──────────────────────────────────────── + + Future _openLogs(PodEntry p) async { + String? container; + final names = + await _service().podContainers(widget.host, p.name, p.namespace); + if (names.length > 1 && mounted) { + container = await showDialog( + context: context, + builder: (_) => SimpleDialog( + title: const Text('Select container'), + children: [ + for (final n in names) + SimpleDialogOption( + child: Text(n), + onPressed: () => Navigator.pop(context, n), + ), + ], + ), + ); + if (container == null) return; + } else if (names.length == 1) { + container = names.first; + } + if (!mounted) return; + _closeLogPanel(); + setState(() { + _logPod = p; + _logContainer = container; + _logLines.clear(); + }); + _logSub = _service() + .streamLogs(widget.host, p.name, p.namespace, _context, + container: container) + .listen( + (line) { + if (!mounted) return; + setState(() { + _logLines.add(line); + if (_logLines.length > 500) _logLines.removeAt(0); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_logScroll.hasClients) { + _logScroll.jumpTo(_logScroll.position.maxScrollExtent); + } + }); + }, + onError: (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Log stream ended')), + ); + _closeLogPanel(); + } + }, + onDone: () { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Log stream ended')), + ); + setState(() => _logPod = null); + } + }, + ); + } + + void _closeLogPanel() { + _logSub?.cancel(); + _logSub = null; + if (mounted) setState(() => _logPod = null); + } + + Widget _logPanel() { + final pod = _logPod!; + return Container( + height: 240, + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Column( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Icon(Icons.article_outlined, + size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Expanded( + child: Text( + 'pod/${pod.name}' + '${_logContainer != null ? ' • $_logContainer' : ''}', + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Close logs', + onPressed: _closeLogPanel, + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: _logLines.isEmpty + ? const Center( + child: SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2))) + : ListView.builder( + controller: _logScroll, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + itemCount: _logLines.length, + itemBuilder: (_, i) => Text( + _logLines[i], + style: const TextStyle( + fontFamily: 'monospace', fontSize: 11), + ), + ), + ), + ], + ), + ); + } + + // ── Port-forward dialog ────────────────────────────── + + Future _showForwardDialog(PodEntry p) async { + await showDialog( + context: context, + builder: (_) => _PortForwardDialog( + pod: p, + onConfirm: (podPort, localPort) => + _startForward(p, podPort, localPort), + ), + ); + } + + Future _startForward( + PodEntry p, int podPort, int localPort) async { + final messenger = ScaffoldMessenger.of(context); + try { + final handle = await _service().startPodPortForward( + widget.host, + p.name, + p.namespace, + _context, + podPort, + localPort, + ); + if (mounted) { + setState(() => _forwards.add(handle)); + messenger.showSnackBar(SnackBar( + content: Text( + 'Forwarding localhost:$localPort → pod/${p.name}:$podPort'), + )); + } else { + await handle.stop(); + } + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Port-forward failed: $e')), + ); + } + } + + Future _stopForward(K8sForwardHandle h) async { + await h.stop(); + if (mounted) setState(() => _forwards.remove(h)); + } + + // ── Active forwards bar ────────────────────────────── + + Widget _activeForwardsBar() { + return Container( + color: AppColors.card, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'ACTIVE FORWARDS', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textTertiary, + letterSpacing: 0.8, + ), + ), + const SizedBox(height: 4), + for (final f in _forwards) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + children: [ + Expanded( + child: Text( + 'pod/${f.pod} :${f.podPort} → localhost:${f.localPort}', + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.onOpenBrowser != null) + TextButton( + onPressed: () => widget.onOpenBrowser!( + 'http://localhost:${f.localPort}'), + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('Open ↗', + style: TextStyle(fontSize: 12)), + ), + TextButton( + onPressed: () => _stopForward(f), + style: TextButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: const Text('■ Stop', + style: TextStyle(fontSize: 12)), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Port-forward dialog ────────────────────────────────── + +class _PortForwardDialog extends StatefulWidget { + const _PortForwardDialog( + {required this.pod, required this.onConfirm}); + final PodEntry pod; + final void Function(int podPort, int localPort) onConfirm; + + @override + State<_PortForwardDialog> createState() => _PortForwardDialogState(); +} + +class _PortForwardDialogState extends State<_PortForwardDialog> { + final _formKey = GlobalKey(); + final _podPortCtrl = TextEditingController(); + final _localPortCtrl = TextEditingController(); + + @override + void dispose() { + _podPortCtrl.dispose(); + _localPortCtrl.dispose(); + super.dispose(); + } + + String? _validatePort(String? v) { + final n = int.tryParse(v ?? ''); + if (n == null || n < 1 || n > 65535) return '1–65535'; + return null; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Forward port'), + content: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('pod/${widget.pod.name}', + style: const TextStyle(fontSize: 12)), + const SizedBox(height: 12), + TextFormField( + controller: _podPortCtrl, + decoration: const InputDecoration( + labelText: 'Pod port', isDense: true), + keyboardType: TextInputType.number, + autofocus: true, + validator: _validatePort, + onChanged: (v) { + if (_localPortCtrl.text.isEmpty) { + _localPortCtrl.text = v; + } + }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _localPortCtrl, + decoration: const InputDecoration( + labelText: 'Local port', isDense: true), + keyboardType: TextInputType.number, + validator: _validatePort, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (!_formKey.currentState!.validate()) return; + final podPort = int.parse(_podPortCtrl.text); + final localPort = int.parse(_localPortCtrl.text); + Navigator.pop(context); + widget.onConfirm(podPort, localPort); + }, + child: const Text('Start Forward'), + ), + ], + ); + } +} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index a9e41949..3795a583 100644 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index 106c882e..d537c7f7 100644 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 6a776701..0705ab1d 100644 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index a4c185cb..1309293e 100644 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index 7bff3675..04b8cc53 100644 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index 70b5f70e..51ef5aa3 100644 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 8280f562..f3898d02 100644 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/app/test/services/container_service_test.dart b/app/test/services/container_service_test.dart index d63d7658..7228d789 100644 --- a/app/test/services/container_service_test.dart +++ b/app/test/services/container_service_test.dart @@ -131,4 +131,22 @@ void main() { expect(cmd, isNot(matches(RegExp(r'-c \w')))); }); }); + + group('parseContextNames', () { + test('parses newline-separated context names', () { + const out = 'minikube\nprod-cluster\ndev-cluster\n'; + expect(ContainerService.parseContextNames(out), + ['minikube', 'prod-cluster', 'dev-cluster']); + }); + + test('ignores blank lines and whitespace', () { + const out = ' minikube \n\n prod \n'; + expect(ContainerService.parseContextNames(out), ['minikube', 'prod']); + }); + + test('empty output returns empty list', () { + expect(ContainerService.parseContextNames(''), isEmpty); + expect(ContainerService.parseContextNames(' \n \n'), isEmpty); + }); + }); } diff --git a/app/test/services/external_edit_service_test.dart b/app/test/services/external_edit_service_test.dart index a76935b1..fa60bbd1 100644 --- a/app/test/services/external_edit_service_test.dart +++ b/app/test/services/external_edit_service_test.dart @@ -129,6 +129,16 @@ void main() { expect(service.activeWatchCount, 0); }); + test('re-opening the same file replaces its watcher instead of stacking', + () async { + await service.openExternal(_host, _entry); + await service.openExternal(_host, _entry); + await service.openExternal(_host, _entry); + // One watcher per (host, remote path) — no duplicate timers polling the + // same file. + expect(service.activeWatchCount, 1); + }); + test('openExternalWith launches with the specified app path', () async { final launched2 = <(Uri, String?)>[]; final serviceWithApp = ExternalEditService( diff --git a/app/test/widgets/host_detail_panel_agent_forwarding_test.dart b/app/test/widgets/host_detail_panel_agent_forwarding_test.dart index fcc85704..0d3845c7 100644 --- a/app/test/widgets/host_detail_panel_agent_forwarding_test.dart +++ b/app/test/widgets/host_detail_panel_agent_forwarding_test.dart @@ -6,6 +6,7 @@ import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/widgets/agent_status_line.dart'; import 'package:yourssh/widgets/host_detail_panel.dart'; @@ -31,6 +32,7 @@ void main() { ChangeNotifierProvider(create: (_) => KeyProvider()), ChangeNotifierProvider( create: (_) => HostProvider(StorageService())), + Provider(create: (_) => SshService(StorageService())), ], child: MaterialApp( home: Scaffold( diff --git a/app/test/widgets/host_detail_panel_chain_test.dart b/app/test/widgets/host_detail_panel_chain_test.dart index de9653d4..ea0c9894 100644 --- a/app/test/widgets/host_detail_panel_chain_test.dart +++ b/app/test/widgets/host_detail_panel_chain_test.dart @@ -8,6 +8,7 @@ import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/widgets/host_detail_panel.dart'; @@ -42,6 +43,7 @@ void main() { ChangeNotifierProvider(create: (_) => KeyProvider()), ChangeNotifierProvider( create: (_) => HostProvider(StorageService())), + Provider(create: (_) => SshService(StorageService())), ], child: MaterialApp( home: Scaffold( diff --git a/app/test/widgets/host_detail_panel_rdp_test.dart b/app/test/widgets/host_detail_panel_rdp_test.dart index 8b6e1b8f..530dcc39 100644 --- a/app/test/widgets/host_detail_panel_rdp_test.dart +++ b/app/test/widgets/host_detail_panel_rdp_test.dart @@ -6,6 +6,7 @@ import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/widgets/host_detail_panel.dart'; @@ -30,6 +31,7 @@ void main() { providers: [ ChangeNotifierProvider(create: (_) => KeyProvider()), ChangeNotifierProvider.value(value: hostProvider), + Provider(create: (_) => SshService(StorageService())), ], child: MaterialApp( home: Scaffold( diff --git a/app/test/widgets/host_detail_panel_recording_test.dart b/app/test/widgets/host_detail_panel_recording_test.dart index b5e704c5..a1f42558 100644 --- a/app/test/widgets/host_detail_panel_recording_test.dart +++ b/app/test/widgets/host_detail_panel_recording_test.dart @@ -6,6 +6,7 @@ import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/widgets/host_detail_panel.dart'; @@ -25,6 +26,7 @@ void main() { ChangeNotifierProvider(create: (_) => KeyProvider()), ChangeNotifierProvider( create: (_) => HostProvider(StorageService())), + Provider(create: (_) => SshService(StorageService())), ], child: MaterialApp( home: Scaffold( diff --git a/app/test/widgets/host_detail_panel_template_test.dart b/app/test/widgets/host_detail_panel_template_test.dart index 7dce01cd..e14f79ab 100644 --- a/app/test/widgets/host_detail_panel_template_test.dart +++ b/app/test/widgets/host_detail_panel_template_test.dart @@ -6,6 +6,7 @@ import 'package:yourssh/models/host.dart'; import 'package:yourssh/providers/host_provider.dart'; import 'package:yourssh/providers/key_provider.dart'; import 'package:yourssh/services/agent_probe.dart'; +import 'package:yourssh/services/ssh_service.dart'; import 'package:yourssh/services/storage_service.dart'; import 'package:yourssh/widgets/host_detail_panel.dart'; @@ -26,6 +27,7 @@ void main() { ChangeNotifierProvider(create: (_) => KeyProvider()), ChangeNotifierProvider( create: (_) => HostProvider(StorageService())), + Provider(create: (_) => SshService(StorageService())), ], child: MaterialApp( home: Scaffold( diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico index 4a6a4fd5..9b081f3a 100644 Binary files a/app/windows/runner/resources/app_icon.ico and b/app/windows/runner/resources/app_icon.ico differ diff --git a/app_icon.png b/app_icon.png deleted file mode 100644 index 70b5f70e..00000000 Binary files a/app_icon.png and /dev/null differ diff --git a/docs/roadmap.md b/docs/roadmap.md index ff9acf84..3283af69 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,24 +1,22 @@ # YourSSH — Roadmap > Direction: **infra workstation for DevOps/SRE managing 10–100+ hosts**, not just an SSH client. -> Current version: 0.1.33 · updated: 2026-06-08 (develop: keyword highlighting, server monitor panel, network discovery, import sources expansion) +> Current version: 0.1.34 · updated: 2026-06-09 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), **Keyword highlighting (develop)** — 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 (develop)** — 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 (develop)** — 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 (develop)** — 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). +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). --- ## P0 — Must-have to retain "power" users -| # | Feature | Purpose | Implementation notes | -|---|---|---|---| -| 1 | **Kubernetes panel completion** | Finish the K8s story: context switcher, `logs -f`, 1-click port-forward | list pods + exec shipped 0.1.12; extends `yourssh_devops` ContainerService | +_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)._ Remaining: logs, restart/stop, Compose awareness. +- **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. diff --git a/docs/superpowers/plans/2026-06-09-k8s-panel-completion.md b/docs/superpowers/plans/2026-06-09-k8s-panel-completion.md new file mode 100644 index 00000000..0c82d843 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-k8s-panel-completion.md @@ -0,0 +1,1293 @@ +# K8s 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:** Complete the Kubernetes panel in the DevOps plugin: context switcher (per-session `--context` flag), in-panel `logs -f` viewer, and 1-click port-forward (kubectl pf + SSH local tunnel to localhost). + +**Architecture:** Add `SshService.execStream` for persistent SSH exec channels; extend `ContainerService` with `listContexts`, `streamLogs`, and `startPodPortForward`; extract `KubernetesPanel` widget from `ContainersScreen` with an in-panel split layout (pod list above, log viewer below); port-forward creates a `ServerSocket + client.forwardLocal` local tunnel backed by a background kubectl process, tracked in a `K8sForwardHandle`. + +**Tech Stack:** Flutter/Dart, dartssh2 (`SSHClient.execute`, `SSHClient.forwardLocal`, `SSHForwardChannel`), `dart:io` (`ServerSocket`), `dart:async` (`StreamController`, `Completer`). + +--- + +## File map + +| File | Action | +|---|---| +| `app/lib/services/ssh_service.dart` | Add `execStream` method (~30 lines, after `exec`) | +| `app/lib/services/container_service.dart` | Add `parseContextNames`, `listContexts`, `currentContext`, `streamLogs`, `startPodPortForward`, `_pipeK8s` | +| `app/lib/models/container_entry.dart` | Add `K8sForwardHandle` class | +| `app/lib/widgets/kubernetes_panel.dart` | New — full K8s tab widget | +| `app/lib/widgets/containers_screen.dart` | Replace K8s body with `KubernetesPanel`, remove `_execPod` (K8s logic moved to panel) | +| `packages/yourssh_devops/lib/src/devops_plugin_config.dart` | Add optional `onOpenBrowser` field | +| `app/lib/plugins/plugin_registry.dart` | Pass `onOpenBrowser` callback to `ContainersScreen` | +| `app/test/services/container_service_test.dart` | Add `parseContextNames` tests | + +--- + +## Task 1: `SshService.execStream` + +**Files:** +- Modify: `app/lib/services/ssh_service.dart` (insert after `exec` method, ~line 1014) + +- [ ] **Step 1: Add `execStream` after `exec`** + + Find the comment `// ── SFTP ───` (currently line ~1015) and insert above it: + + ```dart + /// Opens a persistent SSH exec channel and yields stdout lines. + /// Cancelling the returned stream's subscription closes the channel — + /// the remote process receives SIGHUP. + Stream execStream( + Host host, + String command, { + String? auditSource, + }) { + final controller = StreamController(); + _ensureClient(host).then((client) async { + final SSHSession session; + try { + session = await client.execute(command); + } catch (e) { + controller.addError(e); + unawaited(controller.close()); + return; + } + if (auditSource != null) { + audit?.record(AuditEvent.now( + type: AuditEventType.exec, + host: host, + command: command, + meta: {'source': auditSource}, + )); + } + final sub = session.stdout + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen( + controller.add, + onError: controller.addError, + onDone: () { + session.close(); + if (!controller.isClosed) unawaited(controller.close()); + }, + cancelOnError: false, + ); + controller.onCancel = () async { + await sub.cancel(); + session.close(); + }; + }).catchError(controller.addError); + return controller.stream; + } + ``` + +- [ ] **Step 2: Run analyzer** + + ```bash + cd app && flutter analyze lib/services/ssh_service.dart + ``` + Expected: no new errors. + +- [ ] **Step 3: Commit** + + ```bash + git add app/lib/services/ssh_service.dart + git commit -m "feat(ssh): add execStream for persistent exec channels" + ``` + +--- + +## Task 2: ContainerService — context list methods + tests + +**Files:** +- Modify: `app/lib/services/container_service.dart` +- Modify: `app/test/services/container_service_test.dart` + +- [ ] **Step 1: Write failing tests first** + + Add to `app/test/services/container_service_test.dart`: + + ```dart + group('parseContextNames', () { + test('parses newline-separated context names', () { + const out = 'minikube\nprod-cluster\ndev-cluster\n'; + expect(ContainerService.parseContextNames(out), + ['minikube', 'prod-cluster', 'dev-cluster']); + }); + + test('ignores blank lines and whitespace', () { + const out = ' minikube \n\n prod \n'; + expect(ContainerService.parseContextNames(out), ['minikube', 'prod']); + }); + + test('empty output returns empty list', () { + expect(ContainerService.parseContextNames(''), isEmpty); + expect(ContainerService.parseContextNames(' \n \n'), isEmpty); + }); + }); + ``` + +- [ ] **Step 2: Run tests to confirm they fail** + + ```bash + cd app && flutter test test/services/container_service_test.dart --name "parseContextNames" + ``` + Expected: FAIL — `parseContextNames` not defined. + +- [ ] **Step 3: Add `parseContextNames`, `listContexts`, `currentContext` to ContainerService** + + Add after `podContainers` (around line 43 in container_service.dart): + + ```dart + // ── Contexts ────────────────────────────────────────── + + Future> listContexts(Host host) async { + final r = await ssh.exec(host, 'kubectl config get-contexts -o name', + auditSource: 'devops'); + if (r.exitCode != 0) return const []; + return parseContextNames(r.stdout); + } + + Future currentContext(Host host) async { + final r = await ssh.exec(host, 'kubectl config current-context', + auditSource: 'devops'); + if (r.exitCode != 0) return null; + final name = r.stdout.trim(); + return name.isEmpty ? null : name; + } + + static List parseContextNames(String stdout) { + return stdout + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + } + ``` + +- [ ] **Step 4: Run tests** + + ```bash + cd app && flutter test test/services/container_service_test.dart --name "parseContextNames" + ``` + Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + + ```bash + git add app/lib/services/container_service.dart \ + app/test/services/container_service_test.dart + git commit -m "feat(k8s): add listContexts / parseContextNames to ContainerService" + ``` + +--- + +## Task 3: `K8sForwardHandle` model + +**Files:** +- Modify: `app/lib/models/container_entry.dart` + +- [ ] **Step 1: Add imports and `K8sForwardHandle` at the bottom of `container_entry.dart`** + + Add to the top of the file (after existing imports): + ```dart + import 'dart:async'; + import 'dart:io'; + import 'package:dartssh2/dartssh2.dart'; + ``` + + Add at the bottom of the file: + ```dart + /// Tracks a running `kubectl port-forward` process and the matching local + /// TCP server. Call [stop] to tear both down. + class K8sForwardHandle { + K8sForwardHandle({ + required this.pod, + required this.namespace, + required this.podPort, + required this.localPort, + required StreamSubscription kubectlSub, + required ServerSocket server, + required StreamSubscription serverSub, + required List closers, + }) : _kubectlSub = kubectlSub, + _server = server, + _serverSub = serverSub, + _closers = closers; + + final String pod; + final String namespace; + final int podPort; + final int localPort; + + final StreamSubscription _kubectlSub; + final ServerSocket _server; + final StreamSubscription _serverSub; + final List _closers; + + Future stop() async { + await _serverSub.cancel(); + await _server.close(); + for (final c in List.of(_closers)) { + c(); + } + await _kubectlSub.cancel(); + } + } + ``` + +- [ ] **Step 2: Analyze** + + ```bash + cd app && flutter analyze lib/models/container_entry.dart + ``` + Expected: no errors. + +- [ ] **Step 3: Commit** + + ```bash + git add app/lib/models/container_entry.dart + git commit -m "feat(k8s): add K8sForwardHandle model" + ``` + +--- + +## Task 4: ContainerService — `streamLogs` + `startPodPortForward` + +**Files:** +- Modify: `app/lib/services/container_service.dart` + +- [ ] **Step 1: Add required imports to `container_service.dart`** + + Replace the existing imports at the top: + ```dart + import 'dart:async'; + import 'dart:io'; + import 'dart:math'; + + import 'package:dartssh2/dartssh2.dart'; + + import '../models/container_entry.dart'; + import '../models/host.dart'; + import 'ssh_service.dart'; + ``` + +- [ ] **Step 2: Add `streamLogs` after `currentContext`** + + ```dart + // ── Log streaming ───────────────────────────────────── + + /// Streams stdout lines from `kubectl logs -f`. + /// Cancel the subscription to stop the stream and close the SSH channel. + Stream streamLogs( + Host host, + String pod, + String namespace, + String? context, { + String? container, + int tail = 100, + }) { + final ctxFlag = context != null ? ' --context=$context' : ''; + final cFlag = container != null ? ' -c $container' : ''; + final cmd = + 'kubectl logs -f $pod -n $namespace --tail=$tail$ctxFlag$cFlag'; + return ssh.execStream(host, cmd, auditSource: 'devops'); + } + ``` + +- [ ] **Step 3: Add `startPodPortForward` and `_pipeK8s` before the install hint section** + + Add after `streamLogs`: + + ```dart + // ── Port forwarding ─────────────────────────────────── + + /// Starts `kubectl port-forward` on [host] and creates a local [ServerSocket] + /// on [localPort] that tunnels connections to the pod via SSH. + /// + /// Throws [TimeoutException] if kubectl does not print "Forwarding from" + /// within 10 seconds, or any [Exception] on kubectl error. + Future startPodPortForward( + Host host, + String pod, + String namespace, + String? context, + int podPort, + int localPort, + ) async { + final remotePfPort = 40000 + Random().nextInt(9999); + final ctxFlag = context != null ? ' --context=$context' : ''; + final cmd = 'kubectl port-forward --address 0.0.0.0 pod/$pod ' + '$remotePfPort:$podPort -n $namespace$ctxFlag'; + + final ready = Completer(); + final lines = []; + final logStream = ssh.execStream(host, cmd, auditSource: 'devops'); + + late StreamSubscription kubectlSub; + kubectlSub = logStream.listen( + (line) { + lines.add(line); + if (line.contains('Forwarding from') && !ready.isCompleted) { + ready.complete(); + } + }, + onError: (e) { + if (!ready.isCompleted) ready.completeError(e); + }, + onDone: () { + if (!ready.isCompleted) { + ready.completeError( + Exception('kubectl exited: ${lines.join(' | ')}'), + ); + } + }, + ); + + try { + await ready.future.timeout(const Duration(seconds: 10)); + } catch (e) { + await kubectlSub.cancel(); + rethrow; + } + + final client = await ssh.ensureClient(host); + final server = + await ServerSocket.bind(InternetAddress.loopbackIPv4, localPort); + final closers = []; + + final serverSub = server.listen((socket) async { + try { + final channel = await client.forwardLocal('localhost', remotePfPort); + _pipeK8s(socket, channel, closers); + } catch (_) { + socket.destroy(); + } + }); + + return K8sForwardHandle( + pod: pod, + namespace: namespace, + podPort: podPort, + localPort: localPort, + kubectlSub: kubectlSub, + server: server, + serverSub: serverSub, + closers: closers, + ); + } + + static void _pipeK8s( + Socket local, SSHSocket remote, List closers) { + var done = false; + late final void Function() finish; + finish = () { + if (done) return; + done = true; + local.destroy(); + remote.destroy(); + closers.remove(finish); + }; + closers.add(finish); + unawaited(remote.stream + .cast>() + .pipe(local) + .catchError((_) {}) + .whenComplete(finish)); + unawaited(local + .cast>() + .pipe(remote.sink) + .catchError((_) {}) + .whenComplete(finish)); + } + ``` + +- [ ] **Step 4: Analyze** + + ```bash + cd app && flutter analyze lib/services/container_service.dart + ``` + Expected: no errors. + +- [ ] **Step 5: Run existing container tests to confirm nothing broke** + + ```bash + cd app && flutter test test/services/container_service_test.dart + ``` + Expected: all pass. + +- [ ] **Step 6: Commit** + + ```bash + git add app/lib/services/container_service.dart + git commit -m "feat(k8s): add streamLogs and startPodPortForward to ContainerService" + ``` + +--- + +## Task 5: Create `KubernetesPanel` — skeleton + context switcher + pod list + +**Files:** +- Create: `app/lib/widgets/kubernetes_panel.dart` + +- [ ] **Step 1: Create the file** + + ```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 '../services/ssh_service.dart'; + import '../theme/app_theme.dart'; + + class KubernetesPanel extends StatefulWidget { + const KubernetesPanel({ + super.key, + required this.host, + this.onOpenBrowser, + }); + + final Host host; + /// If non-null, "Open in Browser" buttons are shown for active port-forwards. + final void Function(String url)? onOpenBrowser; + + @override + State createState() => _KubernetesPanelState(); + } + + class _KubernetesPanelState extends State { + ContainerService? _svc; + + // ── Namespace / context ──────────────────────────── + String _namespace = 'default'; + bool _allNamespaces = false; + late TextEditingController _nsCtrl; + + String? _context; // null = omit --context flag + List _contexts = []; + + // ── Pod list ─────────────────────────────────────── + List _pods = []; + bool _loading = false; + String? _error; + + // ── Log panel ───────────────────────────────────── + PodEntry? _logPod; + String? _logContainer; + StreamSubscription? _logSub; + final List _logLines = []; + final ScrollController _logScroll = ScrollController(); + + // ── Port forwards ────────────────────────────────── + final List _forwards = []; + + @override + void initState() { + super.initState(); + _nsCtrl = TextEditingController(text: _namespace); + _loadContexts(); + } + + @override + void didUpdateWidget(KubernetesPanel old) { + super.didUpdateWidget(old); + if (old.host.id != widget.host.id) { + _context = null; + _contexts = []; + _pods = []; + _error = null; + _closeLogPanel(); + _loadContexts(); + } + } + + @override + void dispose() { + _nsCtrl.dispose(); + _logSub?.cancel(); + _logScroll.dispose(); + for (final f in _forwards) { + f.stop(); + } + super.dispose(); + } + + ContainerService _service() => + _svc ??= ContainerService(context.read()); + + Future _loadContexts() async { + final ctxs = await _service().listContexts(widget.host); + if (mounted) setState(() => _contexts = ctxs); + } + + Future _refresh() async { + _namespace = + _nsCtrl.text.trim().isEmpty ? 'default' : _nsCtrl.text.trim(); + setState(() { + _loading = true; + _error = null; + }); + try { + _pods = await _service().listPods( + widget.host, + namespace: _namespace, + allNamespaces: _allNamespaces, + context: _context, + ); + } catch (e) { + _error = e.toString(); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _headerRow(), + if (_forwards.isNotEmpty) _activeForwardsBar(), + Expanded(child: _body()), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: _logPod != null ? _logPanel() : const SizedBox.shrink(), + ), + ], + ); + } + + // ── Header ───────────────────────────────────────── + + Widget _headerRow() { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 8), + child: Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (_contexts.isNotEmpty) _contextDropdown(), + SizedBox( + width: 180, + child: TextField( + enabled: !_allNamespaces, + decoration: const InputDecoration( + labelText: 'Namespace', isDense: true), + controller: _nsCtrl, + onSubmitted: (_) => _refresh(), + ), + ), + Row(mainAxisSize: MainAxisSize.min, children: [ + Checkbox( + value: _allNamespaces, + onChanged: (v) => setState(() { + _allNamespaces = v ?? false; + _refresh(); + }), + ), + const Text('All namespaces'), + ]), + IconButton( + tooltip: 'Refresh', + icon: const Icon(Icons.refresh), + onPressed: _loading ? null : _refresh, + ), + ], + ), + ); + } + + Widget _contextDropdown() { + return DropdownButton( + value: _context, + hint: const Text('Context'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('(default context)'), + ), + for (final c in _contexts) + DropdownMenuItem(value: c, child: Text(c)), + ], + onChanged: (v) => setState(() { + _context = v; + _refresh(); + }), + ); + } + + // ── Body ─────────────────────────────────────────── + + Widget _body() { + if (_loading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 40, color: AppColors.textTertiary), + const SizedBox(height: 12), + Text(_error!, textAlign: TextAlign.center), + ], + ), + ); + } + if (_pods.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inbox, size: 40, color: AppColors.textTertiary), + const SizedBox(height: 12), + const Text('No pods. Tap refresh to scan.'), + const SizedBox(height: 12), + FilledButton(onPressed: _refresh, child: const Text('Scan')), + ], + ), + ); + } + return _podList(); + } + + Widget _podList() { + return ListView.separated( + itemCount: _pods.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + final p = _pods[i]; + return ListTile( + title: Text(p.name), + subtitle: Text( + '${p.namespace} • ${p.ready} • ${p.status}'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.terminal, size: 18), + tooltip: 'Exec', + onPressed: () => _execPod(p), + ), + IconButton( + icon: const Icon(Icons.article_outlined, size: 18), + tooltip: 'Logs', + onPressed: () => _openLogs(p), + ), + IconButton( + icon: const Icon(Icons.swap_horiz, size: 18), + tooltip: 'Forward port', + onPressed: () => _showForwardDialog(p), + ), + ], + ), + ); + }, + ); + } + + // ── Exec ─────────────────────────────────────────── + + Future _execPod(PodEntry p) async { + String? container; + final names = + await _service().podContainers(widget.host, p.name, p.namespace); + if (names.length > 1 && mounted) { + container = await showDialog( + context: context, + builder: (_) => SimpleDialog( + title: const Text('Select container'), + children: [ + for (final n in names) + SimpleDialogOption( + child: Text(n), + onPressed: () => Navigator.pop(context, n), + ), + ], + ), + ); + if (container == null) return; + } else if (names.length == 1) { + container = names.first; + } + if (!mounted) return; + await context.read().connect( + widget.host, + initialCommand: ContainerService.kubectlExecCommand( + p.name, p.namespace, container), + ); + } + + // ── Log panel ────────────────────────────────────── + + Future _openLogs(PodEntry p) async { + String? container; + final names = + await _service().podContainers(widget.host, p.name, p.namespace); + if (names.length > 1 && mounted) { + container = await showDialog( + context: context, + builder: (_) => SimpleDialog( + title: const Text('Select container'), + children: [ + for (final n in names) + SimpleDialogOption( + child: Text(n), + onPressed: () => Navigator.pop(context, n), + ), + ], + ), + ); + if (container == null) return; + } else if (names.length == 1) { + container = names.first; + } + if (!mounted) return; + _closeLogPanel(); + setState(() { + _logPod = p; + _logContainer = container; + _logLines.clear(); + }); + _logSub = _service() + .streamLogs(widget.host, p.name, p.namespace, _context, + container: container) + .listen( + (line) { + if (!mounted) return; + setState(() { + _logLines.add(line); + if (_logLines.length > 500) _logLines.removeAt(0); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_logScroll.hasClients) { + _logScroll + .jumpTo(_logScroll.position.maxScrollExtent); + } + }); + }, + onError: (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Log stream ended')), + ); + _closeLogPanel(); + } + }, + onDone: () { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Log stream ended')), + ); + setState(() => _logPod = null); + } + }, + ); + } + + void _closeLogPanel() { + _logSub?.cancel(); + _logSub = null; + if (mounted) setState(() => _logPod = null); + } + + Widget _logPanel() { + final pod = _logPod!; + return Container( + height: 240, + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Icon(Icons.article_outlined, + size: 14, color: AppColors.textSecondary), + const SizedBox(width: 6), + Expanded( + child: Text( + 'pod/${pod.name}' + '${_logContainer != null ? ' • $_logContainer' : ''}', + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Close logs', + onPressed: _closeLogPanel, + ), + ], + ), + ), + const Divider(height: 1), + // Log lines + Expanded( + child: _logLines.isEmpty + ? const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2))) + : ListView.builder( + controller: _logScroll, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + itemCount: _logLines.length, + itemBuilder: (_, i) => Text( + _logLines[i], + style: const TextStyle( + fontFamily: 'monospace', fontSize: 11), + ), + ), + ), + ], + ), + ); + } + + // ── Port-forward dialog ──────────────────────────── + + Future _showForwardDialog(PodEntry p) async { + await showDialog( + context: context, + builder: (_) => _PortForwardDialog( + pod: p, + onConfirm: (podPort, localPort) => + _startForward(p, podPort, localPort), + ), + ); + } + + Future _startForward(PodEntry p, int podPort, int localPort) async { + final messenger = ScaffoldMessenger.of(context); + try { + final handle = await _service().startPodPortForward( + widget.host, + p.name, + p.namespace, + _context, + podPort, + localPort, + ); + if (mounted) { + setState(() => _forwards.add(handle)); + messenger.showSnackBar(SnackBar( + content: Text( + 'Forwarding localhost:$localPort → pod/${p.name}:$podPort'), + )); + } else { + await handle.stop(); + } + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Port-forward failed: $e')), + ); + } + } + + Future _stopForward(K8sForwardHandle h) async { + await h.stop(); + if (mounted) setState(() => _forwards.remove(h)); + } + + // ── Active forwards bar ──────────────────────────── + + Widget _activeForwardsBar() { + return Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ACTIVE FORWARDS', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: AppColors.textTertiary, + letterSpacing: 0.8)), + const SizedBox(height: 4), + for (final f in _forwards) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + children: [ + Expanded( + child: Text( + 'pod/${f.pod} :${f.podPort} → localhost:${f.localPort}', + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.onOpenBrowser != null) + TextButton( + onPressed: () => widget.onOpenBrowser!( + 'http://localhost:${f.localPort}'), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 0), + minimumSize: Size.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap), + child: const Text('Open ↗', + style: TextStyle(fontSize: 12)), + ), + TextButton( + onPressed: () => _stopForward(f), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 0), + minimumSize: Size.zero, + tapTargetSize: + MaterialTapTargetSize.shrinkWrap), + child: const Text('■ Stop', + style: TextStyle(fontSize: 12)), + ), + ], + ), + ), + ], + ), + ); + } + } + + // ── Port-forward dialog ────────────────────────────────── + + class _PortForwardDialog extends StatefulWidget { + const _PortForwardDialog({required this.pod, required this.onConfirm}); + final PodEntry pod; + final void Function(int podPort, int localPort) onConfirm; + + @override + State<_PortForwardDialog> createState() => _PortForwardDialogState(); + } + + class _PortForwardDialogState extends State<_PortForwardDialog> { + final _formKey = GlobalKey(); + final _podPortCtrl = TextEditingController(); + final _localPortCtrl = TextEditingController(); + + @override + void dispose() { + _podPortCtrl.dispose(); + _localPortCtrl.dispose(); + super.dispose(); + } + + String? _validatePort(String? v) { + final n = int.tryParse(v ?? ''); + if (n == null || n < 1 || n > 65535) return '1–65535'; + return null; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Forward port'), + content: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('pod/${widget.pod.name}', + style: const TextStyle(fontSize: 12)), + const SizedBox(height: 12), + TextFormField( + controller: _podPortCtrl, + decoration: const InputDecoration( + labelText: 'Pod port', isDense: true), + keyboardType: TextInputType.number, + autofocus: true, + validator: _validatePort, + onChanged: (v) { + if (_localPortCtrl.text.isEmpty) { + _localPortCtrl.text = v; + } + }, + ), + const SizedBox(height: 8), + TextFormField( + controller: _localPortCtrl, + decoration: const InputDecoration( + labelText: 'Local port', isDense: true), + keyboardType: TextInputType.number, + validator: _validatePort, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + if (!_formKey.currentState!.validate()) return; + final podPort = int.parse(_podPortCtrl.text); + final localPort = int.parse(_localPortCtrl.text); + Navigator.pop(context); + widget.onConfirm(podPort, localPort); + }, + child: const Text('Start Forward'), + ), + ], + ); + } + } + ``` + + > Note: `listPods` in ContainerService needs a `context` parameter (currently it doesn't have one). Add `{String? context}` as optional named param. Update the command to append `${context != null ? ' --context=$context' : ''}` before running. See Step 2. + +- [ ] **Step 2: Add `context` parameter to `ContainerService.listPods`** + + In `container_service.dart`, update `listPods`: + ```dart + Future> listPods( + Host host, { + String namespace = 'default', + bool allNamespaces = false, + String? context, + }) async { + final scope = allNamespaces ? '-A' : '-n $namespace'; + final ctxFlag = context != null ? ' --context=$context' : ''; + final r = await ssh.exec(host, 'kubectl get pods $scope$ctxFlag', + auditSource: 'devops'); + if (r.exitCode != 0) { + throw Exception(r.stderr.trim().isEmpty ? 'kubectl failed' : r.stderr.trim()); + } + return parsePods(r.stdout, namespace: namespace, allNamespaces: allNamespaces); + } + ``` + +- [ ] **Step 3: Analyze** + + ```bash + cd app && flutter analyze lib/widgets/kubernetes_panel.dart \ + lib/services/container_service.dart + ``` + Expected: no errors. + +- [ ] **Step 4: Commit** + + ```bash + git add app/lib/widgets/kubernetes_panel.dart \ + app/lib/services/container_service.dart + git commit -m "feat(k8s): add KubernetesPanel widget with context switcher, logs, port-forward" + ``` + +--- + +## Task 6: Wire `KubernetesPanel` into `ContainersScreen` + +**Files:** +- Modify: `app/lib/widgets/containers_screen.dart` + +- [ ] **Step 1: Add import and `onOpenBrowser` param to `ContainersScreen`** + + At the top of `containers_screen.dart`, add: + ```dart + import 'kubernetes_panel.dart'; + ``` + + Change the class definition: + ```dart + class ContainersScreen extends StatefulWidget { + const ContainersScreen({super.key, this.onOpenBrowser}); + final void Function(String url)? onOpenBrowser; + + @override + State createState() => _ContainersScreenState(); + } + ``` + +- [ ] **Step 2: Replace K8s body with `KubernetesPanel`** + + In `_body()`, replace the line: + ```dart + return _tab == _Tab.docker ? _dockerList() : _podList(); + ``` + with: + ```dart + if (_tab == _Tab.docker) return _dockerList(); + final host = _hostForSelected(); + if (host == null) return const _CenterHint(icon: Icons.link_off, message: 'Session not found.'); + return KubernetesPanel(host: host, onOpenBrowser: widget.onOpenBrowser); + ``` + +- [ ] **Step 3: Remove now-dead K8s-specific state and methods** + + In `_ContainersScreenState`, remove: + - `List _pods = [];` field + - `String _namespace = 'default';` field + - `bool _allNamespaces = false;` field + - `late final TextEditingController _nsController;` field + - `_nsController` init/dispose in `initState`/`dispose` + - `_namespaceControls()` widget method + - `_podList()` widget method + - `_execPod(PodEntry p)` method (moved to KubernetesPanel) + - The `if (_tab == _Tab.kubernetes) _namespaceControls()` row in `build` + - K8s branch in `_refresh()` (`_pods = await svc.listPods(...)`) + + Also remove the unused import `import '../models/host.dart';` if it's only used by the removed code (check first — it may still be used by `_hostForSelected`). + + > `_refresh()` now only runs for Docker. Keep it for Docker tab only. You may also choose to remove it entirely from `_ContainersScreenState` if you want, since each tab manages its own refresh — but keeping Docker refresh in ContainersScreen is fine. + +- [ ] **Step 4: Remove unused import of `container_entry.dart` PodEntry if applicable** + + Check if `PodEntry` is still referenced in containers_screen.dart after the removal. If not, update the import to only import what's needed. + +- [ ] **Step 5: Analyze** + + ```bash + cd app && flutter analyze lib/widgets/containers_screen.dart + ``` + Expected: no errors. + +- [ ] **Step 6: Run all tests** + + ```bash + cd app && flutter test + ``` + Expected: all pass. + +- [ ] **Step 7: Commit** + + ```bash + git add app/lib/widgets/containers_screen.dart + git commit -m "refactor(containers): delegate K8s tab to KubernetesPanel" + ``` + +--- + +## Task 7: Wire `onOpenBrowser` through DevOpsPluginConfig + +**Files:** +- Modify: `packages/yourssh_devops/lib/src/devops_plugin_config.dart` +- Modify: `app/lib/plugins/plugin_registry.dart` + +- [ ] **Step 1: Add `onOpenBrowser` to `DevOpsPluginConfig`** + + In `devops_plugin_config.dart`, add the field: + ```dart + class DevOpsPluginConfig { + const DevOpsPluginConfig({ + required this.containersScreen, + required this.networkToolsScreen, + required this.cloudflareScreen, + required this.mailCatcherScreen, + required this.mcpServerScreen, + this.onOpenBrowser, // ← new + }); + + // ... existing fields ... + final void Function(String url)? onOpenBrowser; + } + ``` + +- [ ] **Step 2: Check how `containersScreen` is rendered in `devops_hub_screen.dart`** + + Open `packages/yourssh_devops/lib/src/screens/devops_hub_screen.dart` and find where `config.containersScreen` is rendered. Confirm it's just rendered as-is (a Widget). No changes needed — the `onOpenBrowser` callback is passed at construction in plugin_registry. + +- [ ] **Step 3: Update `plugin_registry.dart`** + + Find the `ContainersScreen()` instantiation and update it. The callback can be null for now — `onOpenBrowser` is an optional parameter. Update to: + ```dart + containersScreen: const ContainersScreen(), + ``` + This stays `const` because `onOpenBrowser` defaults to null. If you want to wire up WebTools, pass a callback — but `null` is sufficient for this feature (the "Open" button is simply hidden). + + > Note: If you want to wire WebTools later, change to: + > ```dart + > containersScreen: ContainersScreen( + > onOpenBrowser: (url) { /* launch url via platform */ }, + > ), + > ``` + > This is a follow-up and not required for P0. + +- [ ] **Step 4: Analyze everything** + + ```bash + cd app && flutter analyze + ``` + Expected: no errors. + +- [ ] **Step 5: Run all tests** + + ```bash + cd app && flutter test + ``` + Expected: all pass. + +- [ ] **Step 6: Commit** + + ```bash + git add packages/yourssh_devops/lib/src/devops_plugin_config.dart \ + app/lib/plugins/plugin_registry.dart + git commit -m "feat(devops): add onOpenBrowser to DevOpsPluginConfig" + ``` + +--- + +## Task 8: Manual smoke test + final commit + +- [ ] **Step 1: Run the app** + + ```bash + cd app && flutter run -d macos + ``` + +- [ ] **Step 2: Smoke test context switcher** + - Open DevOps → Containers → Kubernetes tab + - If server has multiple kubectl contexts: confirm dropdown appears and switching context triggers a pod re-list + - If server has one context: confirm dropdown is hidden + +- [ ] **Step 3: Smoke test log streaming** + - Tap the log icon (article) on any running pod + - Confirm log panel slides up at bottom of screen + - Confirm lines stream in real-time + - Tap ✕ to close — confirm panel hides and stream stops + +- [ ] **Step 4: Smoke test port-forward** + - Tap the swap icon on a pod with a known HTTP port (e.g., 8080) + - Enter pod port and local port in dialog, tap Start Forward + - Confirm snackbar appears: "Forwarding localhost:XXXX → pod/name:XXXX" + - Confirm entry appears in active forwards bar + - In a terminal: `curl http://localhost:XXXX` — confirm connection reaches the pod + - Tap ■ Stop — confirm entry disappears + +- [ ] **Step 5: Final commit if needed** + + ```bash + cd app && git add -p && git commit -m "chore(k8s): post-smoke-test fixups" + ``` + +--- + +## Summary + +After all tasks: +- `SshService.execStream` — persistent SSH exec channel as a cancellable `Stream` +- `ContainerService` — `listContexts`, `currentContext`, `streamLogs`, `startPodPortForward` +- `K8sForwardHandle` — tracks kubectl background process + local ServerSocket tunnel +- `KubernetesPanel` — full K8s tab: context switcher, pod list (Exec/Logs/Forward), in-panel 240px log viewer, active forwards bar +- `ContainersScreen` — delegates K8s tab to KubernetesPanel, receives optional `onOpenBrowser` +- All existing tests pass; new `parseContextNames` unit tests added diff --git a/docs/superpowers/specs/2026-06-09-k8s-panel-completion-design.md b/docs/superpowers/specs/2026-06-09-k8s-panel-completion-design.md new file mode 100644 index 00000000..d3787fcb --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-k8s-panel-completion-design.md @@ -0,0 +1,317 @@ +# K8s Panel Completion Design + +**Date:** 2026-06-09 +**Feature:** Kubernetes panel — context switcher, log streaming, 1-click port-forward +**Priority:** P0 + +--- + +## Goal + +Complete the Kubernetes story in the DevOps plugin. The container browser and exec shipped in +0.1.12. This spec adds the three remaining features: + +1. **Context switcher** — select a kubectl context per-session without mutating `~/.kube/config` +2. **Log streaming** — `kubectl logs -f` rendered in an in-panel log viewer +3. **1-click port-forward** — kubectl pf + SSH local tunnel so the port lands on `localhost` of the user's machine + +--- + +## Architecture + +### ContainersScreen refactor (minimal) + +`ContainersScreen` currently owns both Docker and K8s tab bodies. The K8s tab body +(`_podList` + namespace controls) is extracted into a new `KubernetesPanel` widget +(`app/lib/widgets/kubernetes_panel.dart`). `ContainersScreen` keeps the session +selector, tab buttons, Docker list, and namespace controls are moved into +`KubernetesPanel`. + +`ContainersScreen` change: replace `_podList()` call with: +```dart +KubernetesPanel( + host: host, + onExecPod: _execPod, + onOpenBrowser: widget.onOpenBrowser, // nullable, injected from DevOpsPluginConfig +) +``` + +### SshService — `execStream` + +New method on `SshService`: + +```dart +Stream execStream(Host host, String command, {String? auditSource}); +``` + +Opens an SSH exec channel and yields stdout line by line. Cancelling the +`StreamSubscription` closes the channel — the remote process receives SIGHUP and exits. +The stream closes naturally when the remote process exits. Stderr lines are swallowed +(callers that need them can use the existing `exec` method). + +Implementation: `SSHClient.execute(command)` returns an `SSHSession`; `session.stdout` +is a `Stream`. Transform via `utf8.decoder` → `LineSplitter`. + +### ContainerService additions + +```dart +// Returns context names from `kubectl config get-contexts -o name`. +// Returns [] on error (single-context servers). +Future> listContexts(Host host) async { ... } +static List parseContextNames(String stdout) { ... } + +// Returns the current context name, or null on error. +Future currentContext(Host host) async { ... } + +// Streams lines from `kubectl logs -f `. +// context is passed as --context= if non-null. +Stream streamLogs( + Host host, + String pod, + String namespace, + String? context, { + String? container, + int tail = 100, +}) { ... } + +// Starts a port-forward. Throws on timeout (10s) or kubectl error. +Future startPodPortForward( + Host host, + String pod, + String namespace, + String? context, + int podPort, + int localPort, +) async { ... } +``` + +**`startPodPortForward` implementation:** + +1. Pick `remotePfPort` — random int in 40000–49999. +2. Open `execStream` for: + ``` + kubectl port-forward --address 0.0.0.0 pod/ : + -n [--context=] + ``` +3. Collect lines until `"Forwarding from"` appears or 10 s elapses. On timeout: + cancel stream, throw `TimeoutException`. +4. Call `SshService.forwardLocal(host, localPort, 'localhost', remotePfPort)` → + returns an `SSHForwardChannel` (or equivalent handle from dartssh2). +5. Return `K8sForwardHandle`. + +### Model: `K8sForwardHandle` + +```dart +class K8sForwardHandle { + final String pod; + final String namespace; + final int podPort; + final int localPort; + + // internal + final StreamSubscription _kubectlSub; + final dynamic _tunnel; // SSHForwardChannel / whatever dartssh2 exposes + + Future stop() async { + await _kubectlSub.cancel(); + _tunnel.close(); + } +} +``` + +Lives in `app/lib/models/container_entry.dart` (alongside `ContainerEntry`, `PodEntry`). + +--- + +## KubernetesPanel widget + +**File:** `app/lib/widgets/kubernetes_panel.dart` + +### State + +```dart +String? _context; // null = omit --context flag +List _contexts; // loaded once on mount +String _namespace; +bool _allNamespaces; + +List _pods; +bool _loading; +String? _error; + +PodEntry? _logPod; // currently viewed pod +String? _logContainer; // selected container (multi-container pods) +StreamSubscription? _logSub; +List _logLines; // ring buffer, max 500 lines +ScrollController _logScroll; + +List _forwards; +``` + +### Layout + +``` +Column +├── Row: [Context ▼] [Namespace field] [All ns checkbox] [Refresh] +├── if _forwards.isNotEmpty → _ActiveForwardsBar +├── Expanded → pod ListView (or loading / error / empty states) +└── AnimatedSize → _LogPanel (height 240, visible when _logPod != null) +``` + +### Context switcher + +`DropdownButton` with items `[null (Any / default), ...contexts]`. +Null item label: `"(default context)"`. +`listContexts()` is called once in `initState` — result stored in `_contexts`. +If `_contexts.isEmpty` the dropdown is hidden. +Changing context sets `_context` and calls `_refresh()`. + +All kubectl calls in this widget pass `_context` to `ContainerService` methods. + +### Pod list row + +```dart +ListTile( + title: Text(p.name), + subtitle: Text('${p.namespace} • ${p.ready} • ${p.status}'), + trailing: Row(mainAxisSize: MainAxisSize.min, children: [ + IconButton(icon: Icon(Icons.terminal), tooltip: 'Exec', onPressed: () => widget.onExecPod(p)), + IconButton(icon: Icon(Icons.article_outlined), tooltip: 'Logs', onPressed: () => _openLogs(p)), + IconButton(icon: Icon(Icons.swap_horiz), tooltip: 'Forward', onPressed: () => _showForwardDialog(p)), + ]), +) +``` + +### Log panel + +`_openLogs(PodEntry p)`: +1. If multi-container: show `SimpleDialog` to pick container (same as exec dialog). +2. Cancel existing `_logSub` if any. +3. Clear `_logLines`, set `_logPod = p`, `_logContainer = container`. +4. Subscribe to `ContainerService.streamLogs(...)`. +5. On each line: append to `_logLines` (cap at 500), `setState`, scroll to bottom. + +Log panel widget: +``` +Container(height: 240) + Column + ├── Row: [pod/container label] [container ▼ if multi] [✕ close] + └── Expanded → ListView(controller: _logScroll) + items: _logLines mapped to Text(monospace, size 11) +``` + +Auto-scroll: after each `setState`, schedule +`WidgetsBinding.instance.addPostFrameCallback` → +`_logScroll.jumpTo(_logScroll.position.maxScrollExtent)`. + +Close button: cancel `_logSub`, set `_logPod = null`. + +### Port-forward dialog + +`_showForwardDialog(PodEntry p)` → `showDialog` with `_PortForwardDialog`: + +``` +AlertDialog + title: "Forward port" + content: + Column + ├── Text("pod/${p.name}") + ├── TextFormField(label: "Pod port", initialValue: "") + └── TextFormField(label: "Local port", initialValue: same as pod port) + actions: [Cancel, Start Forward] +``` + +On "Start Forward": +1. Validate both ports (int, 1–65535). +2. Pop dialog, call `ContainerService.startPodPortForward(...)`. +3. On success: add handle to `_forwards`, show snackbar `"Forwarding localhost: → pod/:"`. +4. On error: show snackbar with error message. + +### Active forwards bar + +`_ActiveForwardsBar` — thin horizontal strip above the pod list: + +``` +ACTIVE FORWARDS + [pod/my-app :8080 → :8080] [Open ↗] [■ Stop] + [pod/redis :6379 → :6379] [■ Stop] +``` + +"Open in Browser" button calls `widget.onOpenBrowser?.call('http://localhost:$localPort')`. +`widget.onOpenBrowser` is `void Function(String url)?` — injected from `ContainersScreen`, +which receives it from `DevOpsPluginConfig` (new optional field, null = button hidden). + +### Dispose + +```dart +@override +void dispose() { + _logSub?.cancel(); + for (final f in _forwards) { f.stop(); } + _logScroll.dispose(); + super.dispose(); +} +``` + +--- + +## DevOpsPluginConfig change + +Add one optional field: + +```dart +final void Function(String url)? onOpenBrowser; +``` + +Wired in `plugin_registry.dart` to `WebToolsService.openUrl` if the WebTools plugin is +active. Null when WebTools is not installed — the "Open" button is simply hidden. + +--- + +## Namespace controls + +Moved from `ContainersScreen` into `KubernetesPanel`. No behavior change — same +TextField + "All namespaces" checkbox. + +--- + +## Error handling + +| Scenario | Behaviour | +|---|---| +| `listContexts` fails (no kubeconfig) | `_contexts = []`, dropdown hidden | +| `streamLogs` channel dies | Stream closes → `_logSub` done callback sets `_logPod = null`, snackbar "Log stream ended" | +| `startPodPortForward` timeout | Snackbar "Port-forward timed out — is kubectl accessible on this host?" | +| `startPodPortForward` SSH tunnel fails | Handle's `stop()` called automatically, snackbar with error | +| SSH session dropped while forwards active | dartssh2 closes channels → `_kubectlSub` done → handle cleans up; `_forwards` stays in UI until user refreshes or closes panel | + +--- + +## Testing + +**Unit tests** (no SSH): +- `ContainerService.parseContextNames` — newline-separated names, empty input +- `ContainerService.parsePods` existing coverage unchanged + +**Widget tests** (existing pattern — mock `ContainerService`): +- Context dropdown hidden when `listContexts` returns `[]` +- Context dropdown shows items, selecting one calls `_refresh` with correct context +- Log panel appears after tapping Logs; disappears on close +- Port-forward dialog validates port range; calls `startPodPortForward` with correct args +- Active forwards bar hidden when `_forwards` is empty; shows entry after start + +--- + +## Files changed + +| File | Change | +|---|---| +| `app/lib/services/ssh_service.dart` | Add `execStream` method | +| `app/lib/services/container_service.dart` | Add `listContexts`, `currentContext`, `streamLogs`, `startPodPortForward` | +| `app/lib/models/container_entry.dart` | Add `K8sForwardHandle` | +| `app/lib/widgets/containers_screen.dart` | Extract K8s body → delegate to `KubernetesPanel` | +| `app/lib/widgets/kubernetes_panel.dart` | New widget (context switcher + pod list + logs + forwards) | +| `packages/yourssh_devops/lib/src/devops_plugin_config.dart` | Add `onOpenBrowser` field | +| `app/lib/plugins/plugin_registry.dart` | Wire `onOpenBrowser` if WebTools active | +| `app/test/services/container_service_test.dart` | New parse tests | +| `app/test/widgets/kubernetes_panel_test.dart` | New widget tests | diff --git a/packages/yourssh_devops/lib/src/devops_plugin_config.dart b/packages/yourssh_devops/lib/src/devops_plugin_config.dart index 9403461f..56316746 100644 --- a/packages/yourssh_devops/lib/src/devops_plugin_config.dart +++ b/packages/yourssh_devops/lib/src/devops_plugin_config.dart @@ -9,6 +9,7 @@ class DevOpsPluginConfig { final Widget cloudflareScreen; final Widget mailCatcherScreen; final Widget mcpServerScreen; + final void Function(String url)? onOpenBrowser; const DevOpsPluginConfig({ required this.containersScreen, @@ -16,5 +17,6 @@ class DevOpsPluginConfig { required this.cloudflareScreen, required this.mailCatcherScreen, required this.mcpServerScreen, + this.onOpenBrowser, }); } diff --git a/screenshots/01-terminal-ssh/01-hosts-dashboard.png b/screenshots/01-terminal-ssh/01-hosts-dashboard.png new file mode 100644 index 00000000..05bf50b1 Binary files /dev/null and b/screenshots/01-terminal-ssh/01-hosts-dashboard.png differ diff --git a/screenshots/01-terminal-ssh/02-new-host-panel-ssh.png b/screenshots/01-terminal-ssh/02-new-host-panel-ssh.png new file mode 100644 index 00000000..610d34f7 Binary files /dev/null and b/screenshots/01-terminal-ssh/02-new-host-panel-ssh.png differ diff --git a/screenshots/01-terminal-ssh/04-dashboard-with-rdp-badge.png b/screenshots/01-terminal-ssh/04-dashboard-with-rdp-badge.png new file mode 100644 index 00000000..05bf50b1 Binary files /dev/null and b/screenshots/01-terminal-ssh/04-dashboard-with-rdp-badge.png differ diff --git a/screenshots/02-sftp/01-sftp-browser.png b/screenshots/02-sftp/01-sftp-browser.png new file mode 100644 index 00000000..31d5cc94 Binary files /dev/null and b/screenshots/02-sftp/01-sftp-browser.png differ diff --git a/screenshots/03-port-forwarding/01-port-forward-rules.png b/screenshots/03-port-forwarding/01-port-forward-rules.png new file mode 100644 index 00000000..92768f26 Binary files /dev/null and b/screenshots/03-port-forwarding/01-port-forward-rules.png differ diff --git a/screenshots/04-credentials-security/01-keychain.png b/screenshots/04-credentials-security/01-keychain.png new file mode 100644 index 00000000..9e1847c1 Binary files /dev/null and b/screenshots/04-credentials-security/01-keychain.png differ diff --git a/screenshots/04-credentials-security/02-known-hosts.png b/screenshots/04-credentials-security/02-known-hosts.png new file mode 100644 index 00000000..e809f11d Binary files /dev/null and b/screenshots/04-credentials-security/02-known-hosts.png differ diff --git a/screenshots/05-settings/01-settings-general.png b/screenshots/05-settings/01-settings-general.png new file mode 100644 index 00000000..f5846c33 Binary files /dev/null and b/screenshots/05-settings/01-settings-general.png differ diff --git a/screenshots/05-settings/02-settings-terminal.png b/screenshots/05-settings/02-settings-terminal.png new file mode 100644 index 00000000..f5846c33 Binary files /dev/null and b/screenshots/05-settings/02-settings-terminal.png differ diff --git a/screenshots/05-settings/03-settings-sync.png b/screenshots/05-settings/03-settings-sync.png new file mode 100644 index 00000000..f5846c33 Binary files /dev/null and b/screenshots/05-settings/03-settings-sync.png differ diff --git a/screenshots/05-settings/04-settings-updates.png b/screenshots/05-settings/04-settings-updates.png new file mode 100644 index 00000000..f5846c33 Binary files /dev/null and b/screenshots/05-settings/04-settings-updates.png differ diff --git a/screenshots/06-plugins/01-plugins.png b/screenshots/06-plugins/01-plugins.png new file mode 100644 index 00000000..f4f97db4 Binary files /dev/null and b/screenshots/06-plugins/01-plugins.png differ diff --git a/screenshots/10-audit-recording/01-audit-log.png b/screenshots/10-audit-recording/01-audit-log.png new file mode 100644 index 00000000..c4e2cd26 Binary files /dev/null and b/screenshots/10-audit-recording/01-audit-log.png differ diff --git a/screenshots/10-audit-recording/02-recording-library.png b/screenshots/10-audit-recording/02-recording-library.png new file mode 100644 index 00000000..749032a8 Binary files /dev/null and b/screenshots/10-audit-recording/02-recording-library.png differ