Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cbe2e0a
docs: mark connection proxy shipped in 0.1.37 roadmap; refresh Top 3
thangnm93 Jun 18, 2026
f9b2486
docs(spec): add Docker panel completion design (logs, lifecycle, Comp…
thangnm93 Jun 18, 2026
cc51912
docs(docker): Compose panel completion plan + spec sync
thangnm93 Jun 18, 2026
bc2475f
feat(docker): add ComposeStack and ComposeService models
thangnm93 Jun 18, 2026
ab52bdb
feat(docker): container logs streaming + stop/start/restart
thangnm93 Jun 18, 2026
25c99a0
test(docker): cover startContainer command + auditSource passthrough
thangnm93 Jun 18, 2026
e63703c
feat(docker): Compose discovery, service actions, and log streaming
thangnm93 Jun 18, 2026
b438b0b
fix(docker): parseComposeLs survives slash-less ConfigFiles; cover li…
thangnm93 Jun 18, 2026
bdd82b8
feat(docker): DockerPanel widget with logs and lifecycle actions
thangnm93 Jun 18, 2026
3a87fa0
fix(docker): list all containers (ps -a) for Start; guard _refresh af…
thangnm93 Jun 18, 2026
44eb293
feat(docker): ComposePanel widget with stack discovery and service ma…
thangnm93 Jun 18, 2026
2f0e035
fix(docker): guard ComposePanel _stackAction after dispose; unify err…
thangnm93 Jun 18, 2026
d954957
feat(docker): wire DockerPanel + ComposePanel into ContainersScreen
thangnm93 Jun 18, 2026
8883311
fix(docker): surface runtime-scan errors; ComposePanel log scroll + c…
thangnm93 Jun 18, 2026
8e828be
fix(docker): shell-quote paths, guard disposes, fix selectStack race …
thangnm93 Jun 19, 2026
e218152
fix(docker): quote service/id, gen-guard stackAction refetch, AppSnac…
thangnm93 Jun 19, 2026
e26ad11
fix(docker): quote container id in streamDockerLogs for consistency
thangnm93 Jun 19, 2026
80975d7
test(docker): drop unused import in compose_panel_test
thangnm93 Jun 23, 2026
73ca0fa
chore: release 0.1.38 — Docker panel completion (version, changelog, …
thangnm93 Jun 23, 2026
0fd0641
fix(docker): address code-review findings (compose discovery, lifecyc…
thangnm93 Jun 23, 2026
f3301f2
Merge pull request #79 from YoursshLabs/feat/docker-panel-completion
thangnm93 Jun 23, 2026
993f3cc
Merge branch 'master' into develop
thangnm93 Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.38] — 2026-06-23

### Added
- **Docker panel completion** — the Containers screen now has dedicated **Docker** and **Compose** tabs alongside Kubernetes. Docker (`DockerPanel`): a live container list (`docker ps -a`) with per-container **Stop / Start / Restart**, one-click **Exec** into a new SSH tab, and an inline follow-mode **log viewer** (`docker logs -f --tail`, auto-scroll with manual-scroll detach, 2000-line cap). Compose (`ComposePanel`): stack discovery via `docker compose ls` merged with a `find` sweep of `~ /opt /srv /home` (ls entries take precedence) plus a manual compose-file path add; per-stack **Up / Down**, per-service **Start / Stop**, service replica counts, and per-service follow-mode logs. All commands run with `auditSource: 'devops'` and single-quote project paths; non-zero exits surface the real stderr.

---

## [0.1.37] — 2026-06-18

### Added
Expand Down
28 changes: 28 additions & 0 deletions app/lib/models/container_entry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,31 @@ class K8sForwardHandle {
}
}
}

/// One Docker Compose project discovered on the remote host.
class ComposeStack {
final String name;
final String projectDir;
final String status;

const ComposeStack({
required this.name,
required this.projectDir,
required this.status,
});
}

/// One service within a Docker Compose stack.
class ComposeService {
final String name;
final String status;
final String image;
final int replicas;

const ComposeService({
required this.name,
required this.status,
required this.image,
this.replicas = 1,
});
}
271 changes: 269 additions & 2 deletions app/lib/services/container_service.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';

Expand All @@ -19,13 +20,41 @@ class ContainerService {
static const _dockerFormat = '{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}';

Future<List<ContainerEntry>> listDockerContainers(Host host) async {
final r = await ssh.exec(host, "docker ps --format '$_dockerFormat'", auditSource: 'devops');
final r = await ssh.exec(host, "docker ps -a --format '$_dockerFormat'", auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(r.stderr.trim().isEmpty ? 'docker ps failed' : r.stderr.trim());
}
return parseDockerPs(r.stdout);
}

// ── Docker logs + lifecycle ───────────────────────────

/// Streams stdout+stderr of `docker logs -f <id>`.
Stream<String> streamDockerLogs(Host host, String id, {int tail = 100}) =>
ssh.execStream(host, 'docker logs -f --tail=$tail ${_shq(id)} 2>&1',
auditSource: 'devops');

Future<void> stopContainer(Host host, String id) async {
final r = await ssh.exec(host, 'docker stop ${_shq(id)}', auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(r.stderr.trim().isEmpty ? 'docker stop failed' : r.stderr.trim());
}
}

Future<void> startContainer(Host host, String id) async {
final r = await ssh.exec(host, 'docker start ${_shq(id)}', auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(r.stderr.trim().isEmpty ? 'docker start failed' : r.stderr.trim());
}
}

Future<void> restartContainer(Host host, String id) async {
final r = await ssh.exec(host, 'docker restart ${_shq(id)}', auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(r.stderr.trim().isEmpty ? 'docker restart failed' : r.stderr.trim());
}
}

// ── Kubernetes ────────────────────────────────────────
Future<List<PodEntry>> listPods(
Host host, {
Expand Down Expand Up @@ -288,12 +317,16 @@ class ContainerService {
return RuntimeAvailability.available;
}

// ── Shell quoting ─────────────────────────────────────
/// POSIX single-quote-escapes [s] for safe shell interpolation.
static String _shq(String s) => "'${s.replaceAll("'", r"'\''")}'";

// ── Exec command builders ─────────────────────────────
static const _shFallback =
"sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'";

static String dockerExecCommand(String id) =>
'docker exec -it $id $_shFallback';
'docker exec -it ${_shq(id)} $_shFallback';

static String kubectlExecCommand(
String pod, String namespace, String? container) {
Expand Down Expand Up @@ -321,4 +354,238 @@ class ContainerService {
}
return 'Check your kubeconfig / RBAC permissions.';
}

// ── Compose static parsers ────────────────────────────

/// Parses `docker compose ls --format json` output.
/// Returns [] on any parse failure.
static List<ComposeStack> parseComposeLs(String json) {
if (json.trim().isEmpty) return const [];
try {
final list = jsonDecode(json) as List<dynamic>;
return list.map((e) {
final map = e as Map<String, dynamic>;
final configFiles = (map['ConfigFiles'] as String? ?? '').trim();
// ConfigFiles may be comma-separated; take the first to derive projectDir.
final firstFile = configFiles.split(',').first.trim();
final projectDir = firstFile.contains('/')
? firstFile.substring(0, firstFile.lastIndexOf('/'))
: '';
return ComposeStack(
name: (map['Name'] as String? ?? '').trim(),
projectDir: projectDir,
status: (map['Status'] as String? ?? '').trim(),
);
}).where((s) => s.name.isNotEmpty && s.projectDir.isNotEmpty).toList();
} catch (_) {
return const [];
}
}

/// Parses `find ... -name "docker-compose.yml" -o -name "compose.yml"` output.
/// Deduplicates by projectDir.
static List<ComposeStack> parseComposeFindOutput(String stdout) {
final seen = <String>{};
final out = <ComposeStack>[];
for (final line in stdout.split('\n')) {
final path = line.trim();
if (path.isEmpty) continue;
final lastSlash = path.lastIndexOf('/');
if (lastSlash < 0) continue;
final projectDir = path.substring(0, lastSlash);
if (projectDir.isEmpty) continue;
if (seen.contains(projectDir)) continue;
seen.add(projectDir);
final name = projectDir.substring(projectDir.lastIndexOf('/') + 1);
out.add(ComposeStack(name: name, projectDir: projectDir, status: 'unknown'));
}
return out;
}

/// Derives a [ComposeStack] from an absolute compose-file path (used by the
/// manual "add by path" flow). Returns null when [path] is not absolute.
/// A root-level file (`/compose.yml`) maps to projectDir `/`, never the empty
/// string — an empty projectDir would make `cd '' && docker compose …` run in
/// the login dir against the wrong project.
static ComposeStack? composeStackFromPath(String path) {
final p = path.trim();
if (!p.startsWith('/')) return null;
final lastSlash = p.lastIndexOf('/');
final projectDir = lastSlash == 0 ? '/' : p.substring(0, lastSlash);
final name =
projectDir == '/' ? '/' : projectDir.substring(projectDir.lastIndexOf('/') + 1);
return ComposeStack(name: name, projectDir: projectDir, status: 'unknown');
}

/// Parses `docker compose ps --format json` output (JSON array or JSONL).
/// Groups by service name, counts replicas.
static List<ComposeService> parseComposePs(String stdout) {
if (stdout.trim().isEmpty) return const [];
final items = <Map<String, dynamic>>[];
try {
// Try JSON array first.
final decoded = jsonDecode(stdout.trim());
if (decoded is List) {
for (final e in decoded) {
if (e is Map<String, dynamic>) items.add(e);
}
}
} catch (_) {
// Fall through to JSONL.
}
if (items.isEmpty) {
// Try JSONL (one JSON object per line).
for (final line in stdout.split('\n')) {
final t = line.trim();
if (t.isEmpty) continue;
try {
final obj = jsonDecode(t);
if (obj is Map<String, dynamic>) items.add(obj);
} catch (_) {
continue;
}
}
}
if (items.isEmpty) return const [];

// Group by "Service" field, count replicas.
final byService = <String, List<Map<String, dynamic>>>{};
for (final item in items) {
final svc = (item['Service'] as String? ?? '').trim();
if (svc.isEmpty) continue;
(byService[svc] ??= []).add(item);
}
return byService.entries.map((e) {
final group = e.value;
final first = group.first;
final states = group.map((m) => (m['State'] as String? ?? '').trim()).toList();
final String status;
if (states.every((s) => s == 'running')) {
status = 'running';
} else if (states.any((s) => s == 'running')) {
status = 'degraded';
} else {
// First non-empty state (a missing/blank State field would otherwise
// render a blank status label and offer the wrong lifecycle action).
status = states.firstWhere((s) => s.isNotEmpty, orElse: () => 'unknown');
}
return ComposeService(
name: e.key,
status: status,
image: (first['Image'] as String? ?? '').trim(),
replicas: group.length,
);
}).toList();
}

// ── Compose ───────────────────────────────────────────

/// Discovers Compose stacks by running `docker compose ls` (active projects)
/// and `find` (files on disk). Results are merged; ls entries take precedence
/// when the same projectDir appears in both.
Future<List<ComposeStack>> discoverComposeStacks(Host host) async {
final results = await Future.wait([
_composeLsStacks(host),
_composeFindStacks(host),
]);
final lsStacks = results[0];
final findStacks = results[1];
// Merge: ls entries take precedence.
final seen = {for (final s in lsStacks) s.projectDir};
return [
...lsStacks,
...findStacks.where((s) => !seen.contains(s.projectDir)),
];
}

Future<List<ComposeStack>> _composeLsStacks(Host host) async {
final r = await ssh.exec(host, 'docker compose ls --format json 2>/dev/null',
auditSource: 'devops');
if (r.exitCode != 0) return const [];
return parseComposeLs(r.stdout);
}

Future<List<ComposeStack>> _composeFindStacks(Host host) async {
const findCmd =
r"find ~ /opt /srv /home -maxdepth 3 \( -name 'docker-compose.yml' -o -name 'compose.yml' \) 2>/dev/null";
final r = await ssh.exec(host, findCmd, auditSource: 'devops');
// `find` exits non-zero if ANY starting root is missing (e.g. no /srv or
// /opt) even though it printed matches from the roots that do exist, so we
// parse stdout regardless of exit code. parseComposeFindOutput is robust to
// empty/garbage input (returns []), so a genuine failure still degrades safely.
return parseComposeFindOutput(r.stdout);
}

Future<List<ComposeService>> listComposeServices(
Host host, ComposeStack stack) async {
final r = await ssh.exec(
host, "cd ${_shq(stack.projectDir)} && docker compose ps --format json 2>/dev/null",
auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(
r.stderr.trim().isEmpty ? 'docker compose ps failed' : r.stderr.trim());
}
return parseComposePs(r.stdout);
}

Future<void> composeUp(Host host, ComposeStack stack) async {
final r = await ssh.exec(
host, "cd ${_shq(stack.projectDir)} && docker compose up -d",
auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(
r.stderr.trim().isEmpty ? 'docker compose up failed' : r.stderr.trim());
}
}

Future<void> composeDown(Host host, ComposeStack stack) async {
final r = await ssh.exec(
host, "cd ${_shq(stack.projectDir)} && docker compose down",
auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(
r.stderr.trim().isEmpty ? 'docker compose down failed' : r.stderr.trim());
}
}

Future<void> startComposeService(
Host host, ComposeStack stack, String service) async {
final r = await ssh.exec(
host, "cd ${_shq(stack.projectDir)} && docker compose start ${_shq(service)}",
auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(
r.stderr.trim().isEmpty ? 'docker compose start failed' : r.stderr.trim());
}
}

Future<void> stopComposeService(
Host host, ComposeStack stack, String service) async {
final r = await ssh.exec(
host, "cd ${_shq(stack.projectDir)} && docker compose stop ${_shq(service)}",
auditSource: 'devops');
if (r.exitCode != 0) {
throw Exception(
r.stderr.trim().isEmpty ? 'docker compose stop failed' : r.stderr.trim());
}
}

Stream<String> streamComposeServiceLogs(
Host host, ComposeStack stack, String service, {int tail = 100}) =>
ssh.execStream(
host,
"cd ${_shq(stack.projectDir)} && docker compose logs -f --tail=$tail ${_shq(service)} 2>&1",
auditSource: 'devops');

/// Validates a Compose file at [path] by listing its services. Returns the
/// service names; throws on a non-Compose / invalid file.
Future<List<String>> validateComposeFile(Host host, String path) async {
final r = await ssh.exec(host, 'docker compose -f ${_shq(path)} config --services 2>&1',
auditSource: 'devops');
if (r.exitCode != 0) {
final detail = r.stdout.trim();
throw Exception(detail.isEmpty ? 'Not a valid Compose file: $path' : 'Not a valid Compose file: $path — $detail');
}
return r.stdout.split('\n').map((l) => l.trim()).where((l) => l.isNotEmpty).toList();
}
}
Loading
Loading