From a4c033d44a18871bdd0ca8a76e3cd6d969662fa1 Mon Sep 17 00:00:00 2001 From: dyllon Date: Sun, 10 May 2026 16:38:39 +0800 Subject: [PATCH 1/6] feat: add container controls and detail inspector --- ColimaStack/AppState.swift | 84 ++ .../Models/BackendResourceModels.swift | 86 ++ .../Services/DockerResourceService.swift | 104 +++ ColimaStack/Views/WorkspaceScreens.swift | 791 +++++++++++++++++- .../AppStateBackendAggregationTests.swift | 149 +++- .../DockerResourceServiceTests.swift | 89 ++ ColimaStackUITests/ColimaStackUITests.swift | 21 + design/mockups/screen_inventory.md | 14 +- docs/src/content/docs/docker/containers.md | 36 +- .../src/content/docs/reference/command-api.md | 31 +- 10 files changed, 1367 insertions(+), 38 deletions(-) diff --git a/ColimaStack/AppState.swift b/ColimaStack/AppState.swift index b96495d..5abd656 100644 --- a/ColimaStack/AppState.swift +++ b/ColimaStack/AppState.swift @@ -69,6 +69,7 @@ final class AppState: ObservableObject { private let colima: ColimaControlling private let backend: BackendSnapshotProviding? + private let dockerContainerController: DockerContainerCommandControlling private let searchIndexer: BackendSearchIndexing private let userDefaults: UserDefaults? private let maxMonitorHistorySamples = 90 @@ -81,11 +82,13 @@ final class AppState: ObservableObject { colima: ColimaControlling, profiles: [ColimaProfile] = [], backend: BackendSnapshotProviding? = nil, + dockerContainerController: DockerContainerCommandControlling = LiveDockerContainerCommandService(), searchIndexer: BackendSearchIndexing? = nil, userDefaults: UserDefaults? = nil ) { self.colima = colima self.backend = backend + self.dockerContainerController = dockerContainerController self.searchIndexer = searchIndexer ?? BackendSearchIndexer() self.userDefaults = userDefaults self.profiles = profiles @@ -321,6 +324,60 @@ final class AppState: ObservableObject { await runCommand("Update \(selectedProfileID)") { try await colima.update(profile: selectedProfileID) } } + func startContainer(_ container: DockerContainerResource) async { + await runDockerContainerCommand("Start container \(container.displayName)") { + try await dockerContainerController.start(containerID: container.id, context: selectedDockerContext) + } + } + + func stopContainer(_ container: DockerContainerResource) async { + await runDockerContainerCommand("Stop container \(container.displayName)") { + try await dockerContainerController.stop(containerID: container.id, context: selectedDockerContext) + } + } + + func restartContainer(_ container: DockerContainerResource) async { + await runDockerContainerCommand("Restart container \(container.displayName)") { + try await dockerContainerController.restart(containerID: container.id, context: selectedDockerContext) + } + } + + func pauseContainer(_ container: DockerContainerResource) async { + await runDockerContainerCommand("Pause container \(container.displayName)") { + try await dockerContainerController.pause(containerID: container.id, context: selectedDockerContext) + } + } + + func resumeContainer(_ container: DockerContainerResource) async { + await runDockerContainerCommand("Resume container \(container.displayName)") { + try await dockerContainerController.resume(containerID: container.id, context: selectedDockerContext) + } + } + + func killContainer(_ container: DockerContainerResource) async { + await runDockerContainerCommand("Kill container \(container.displayName)") { + try await dockerContainerController.kill(containerID: container.id, context: selectedDockerContext) + } + } + + func removeContainer(_ container: DockerContainerResource) async { + await runDockerContainerCommand("Delete container \(container.displayName)") { + try await dockerContainerController.remove(containerID: container.id, context: selectedDockerContext) + } + } + + func containerLogs(_ container: DockerContainerResource, timestamps: Bool, tail: Int) async throws -> String { + try await dockerContainerController.logs(containerID: container.id, context: selectedDockerContext, timestamps: timestamps, tail: tail) + } + + func inspectContainer(_ container: DockerContainerResource) async throws -> String { + try await dockerContainerController.inspect(containerID: container.id, context: selectedDockerContext) + } + + func terminalCommand(for container: DockerContainerResource, shell: String = "/bin/sh") -> String { + dockerContainerController.terminalCommand(containerID: container.id, context: selectedDockerContext, shell: shell) + } + func setKubernetes(enabled: Bool) async { guard let selectedProfileID else { return } await runCommand(enabled ? "Start Kubernetes" : "Stop Kubernetes") { @@ -370,6 +427,10 @@ final class AppState: ObservableObject { return configuration } + private var selectedDockerContext: String? { + selectedProfileDetail?.dockerContext.nonEmpty ?? selectedProfile?.dockerContext.nonEmpty + } + func saveEditingConfiguration() async { let configuration = editingConfiguration let mode = profileEditorMode ?? .create @@ -420,6 +481,29 @@ final class AppState: ObservableObject { } } + @discardableResult + private func runDockerContainerCommand(_ label: String, operation: () async throws -> ManagedCommandRun) async -> Bool { + activeOperation = label + var entry = CommandLogEntry(date: Date(), command: label, status: .running, output: "") + commandLog.insert(entry, at: 0) + trimCommandLog() + defer { activeOperation = nil } + do { + let run = try await operation() + entry.status = .succeeded + entry.output = cappedLog(run.combinedOutput) + replaceCommandEntry(entry) + await refreshAll() + return true + } catch { + entry.status = .failed(error.localizedDescription) + entry.output = cappedLog(error.localizedDescription) + replaceCommandEntry(entry) + presentedError = AppError(message: error.localizedDescription) + return false + } + } + private func replaceCommandEntry(_ entry: CommandLogEntry) { if let index = commandLog.firstIndex(where: { $0.id == entry.id }) { commandLog[index] = entry diff --git a/ColimaStack/Models/BackendResourceModels.swift b/ColimaStack/Models/BackendResourceModels.swift index 07dfc3b..a5c6e54 100644 --- a/ColimaStack/Models/BackendResourceModels.swift +++ b/ColimaStack/Models/BackendResourceModels.swift @@ -380,6 +380,92 @@ nonisolated struct DockerContainerResource: Identifiable, Hashable, Codable, Sen if normalized == "exited" || normalized == "restarting" || normalized == "paused" { return .warning } return .unknown } + + var displayName: String { + name.isEmpty ? id : name + } + + var normalizedState: String { + state.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + var composeProject: String? { + labels["com.docker.compose.project"]?.nonEmpty + } + + var composeService: String? { + labels["com.docker.compose.service"]?.nonEmpty + } + + var availableActions: Set { + var actions: Set = [.logs, .inspect, .copyID, .copyImage] + if !ports.isEmpty { actions.insert(.copyPorts) } + if !portBindings.isEmpty { actions.insert(.openPort) } + + switch normalizedState { + case "running": + actions.formUnion([.stop, .restart, .pause, .terminal]) + case "paused": + actions.formUnion([.resume, .stop, .restart]) + case "dead": + actions.formUnion([.kill, .delete]) + case "restarting": + actions.formUnion([.stop, .kill, .restart]) + case "exited", "created", "stopped": + actions.formUnion([.start, .delete]) + default: + actions.formUnion([.start, .restart, .delete]) + } + return actions + } +} + +nonisolated enum DockerContainerAction: String, CaseIterable, Hashable, Codable, Sendable { + case start + case stop + case restart + case pause + case resume + case kill + case delete + case logs + case inspect + case terminal + case openPort + case copyID + case copyImage + case copyPorts +} + +nonisolated struct DockerComposeContainerGroup: Identifiable, Hashable, Codable, Sendable { + var projectName: String + var containers: [DockerContainerResource] + + var id: String { projectName } + + var runningCount: Int { + containers.filter { $0.normalizedState == "running" }.count + } + + var services: [String] { + Array(Set(containers.compactMap(\.composeService))).sorted() + } + + static func groups(from containers: [DockerContainerResource]) -> [DockerComposeContainerGroup] { + Dictionary(grouping: containers.compactMap { container -> (String, DockerContainerResource)? in + guard let project = container.composeProject else { return nil } + return (project, container) + }, by: { $0.0 }) + .map { project, values in + DockerComposeContainerGroup( + projectName: project, + containers: values.map(\.1).sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + ) + } + .sorted { $0.projectName.localizedCaseInsensitiveCompare($1.projectName) == .orderedAscending } + } } nonisolated struct DockerImageResource: Identifiable, Hashable, Codable, Sendable { diff --git a/ColimaStack/Services/DockerResourceService.swift b/ColimaStack/Services/DockerResourceService.swift index 8be8416..2085b5f 100644 --- a/ColimaStack/Services/DockerResourceService.swift +++ b/ColimaStack/Services/DockerResourceService.swift @@ -5,6 +5,110 @@ protocol DockerResourceProviding { func snapshot(context: String?) async throws -> DockerResourceSnapshot } +nonisolated protocol DockerContainerCommandControlling { + func start(containerID: String, context: String?) async throws -> ManagedCommandRun + func stop(containerID: String, context: String?) async throws -> ManagedCommandRun + func restart(containerID: String, context: String?) async throws -> ManagedCommandRun + func pause(containerID: String, context: String?) async throws -> ManagedCommandRun + func resume(containerID: String, context: String?) async throws -> ManagedCommandRun + func kill(containerID: String, context: String?) async throws -> ManagedCommandRun + func remove(containerID: String, context: String?) async throws -> ManagedCommandRun + func logs(containerID: String, context: String?, timestamps: Bool, tail: Int) async throws -> String + func inspect(containerID: String, context: String?) async throws -> String + func terminalCommand(containerID: String, context: String?, shell: String) -> String +} + +nonisolated struct LiveDockerContainerCommandService: DockerContainerCommandControlling { + private let commandRunner: CommandRunProviding + + init(commandRunner: CommandRunProviding = LiveCommandRunService()) { + self.commandRunner = commandRunner + } + + func start(containerID: String, context: String?) async throws -> ManagedCommandRun { + try await run(context: context, arguments: ["start", containerID], purpose: "Start container \(containerID)") + } + + func stop(containerID: String, context: String?) async throws -> ManagedCommandRun { + try await run(context: context, arguments: ["stop", containerID], purpose: "Stop container \(containerID)") + } + + func restart(containerID: String, context: String?) async throws -> ManagedCommandRun { + try await run(context: context, arguments: ["restart", containerID], purpose: "Restart container \(containerID)") + } + + func pause(containerID: String, context: String?) async throws -> ManagedCommandRun { + try await run(context: context, arguments: ["pause", containerID], purpose: "Pause container \(containerID)") + } + + func resume(containerID: String, context: String?) async throws -> ManagedCommandRun { + try await run(context: context, arguments: ["unpause", containerID], purpose: "Resume container \(containerID)") + } + + func kill(containerID: String, context: String?) async throws -> ManagedCommandRun { + try await run(context: context, arguments: ["kill", containerID], purpose: "Kill container \(containerID)") + } + + func remove(containerID: String, context: String?) async throws -> ManagedCommandRun { + try await run(context: context, arguments: ["rm", containerID], purpose: "Delete container \(containerID)") + } + + func logs(containerID: String, context: String?, timestamps: Bool, tail: Int) async throws -> String { + var arguments = ["logs"] + if timestamps { + arguments.append("--timestamps") + } + arguments += ["--tail", "\(tail)", containerID] + let run = try await run(context: context, arguments: arguments, purpose: "Fetch container logs \(containerID)") + guard run.succeeded else { + throw DockerContainerCommandError(run: run) + } + return run.standardOutput + } + + func inspect(containerID: String, context: String?) async throws -> String { + let run = try await run(context: context, arguments: ["inspect", containerID], purpose: "Inspect container \(containerID)") + guard run.succeeded else { + throw DockerContainerCommandError(run: run) + } + return run.standardOutput + } + + func terminalCommand(containerID: String, context: String?, shell: String = "/bin/sh") -> String { + (["docker"] + dockerArguments(context: context, subcommand: ["exec", "-it", containerID, shell])) + .joined(separator: " ") + } + + private func run(context: String?, arguments: [String], purpose: String) async throws -> ManagedCommandRun { + let run = try await commandRunner.run( + ManagedCommandRequest( + toolName: "docker", + arguments: dockerArguments(context: context, subcommand: arguments), + timeout: 30, + purpose: purpose + ) + ) + guard run.succeeded else { + throw DockerContainerCommandError(run: run) + } + return run + } + + private func dockerArguments(context: String?, subcommand: [String]) -> [String] { + guard let context, !context.isEmpty else { return subcommand } + return ["--context", context] + subcommand + } +} + +struct DockerContainerCommandError: LocalizedError { + var run: ManagedCommandRun + + var errorDescription: String? { + run.combinedOutput.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty + ?? "Docker command exited with status \(run.terminationStatus)." + } +} + struct LiveDockerResourceService: DockerResourceProviding { private let commandRunner: CommandRunProviding diff --git a/ColimaStack/Views/WorkspaceScreens.swift b/ColimaStack/Views/WorkspaceScreens.swift index a90d9f0..89c256e 100644 --- a/ColimaStack/Views/WorkspaceScreens.swift +++ b/ColimaStack/Views/WorkspaceScreens.swift @@ -1,3 +1,4 @@ +import AppKit import Charts import SwiftUI @@ -274,10 +275,36 @@ struct ContainersScreen: View { @EnvironmentObject private var appState: AppState let searchText: String + @State private var selectedContainerID: DockerContainerResource.ID? + @State private var filter: ContainerStateFilter = .all + @State private var selectedTab: ContainerInspectorTab = .overview + @State private var logsText = "" + @State private var inspectText = "" + @State private var logsError: String? + @State private var inspectError: String? + @State private var logsIncludeTimestamps = false + @State private var logsTail = 200 + @State private var logsSearch = "" + @State private var inspectSearch = "" + @State private var deleteCandidate: DockerContainerResource? + private let columns = Array(repeating: GridItem(.flexible(), spacing: 12), count: 4) + private var allContainers: [DockerContainerResource] { + appState.backendSnapshot?.docker?.containers ?? [] + } + private var containers: [DockerContainerResource] { - (appState.backendSnapshot?.docker?.containers ?? []).filter { - matchesSearch(searchText, values: [$0.name, $0.image, $0.state, $0.status, $0.ports]) + allContainers.filter { + filter.includes($0) && matchesSearch(searchText, values: [ + $0.name, + $0.id, + $0.image, + $0.state, + $0.status, + $0.ports, + $0.composeProject ?? "", + $0.composeService ?? "" + ] + Array($0.labels.keys) + Array($0.labels.values)) }.sorted { ($0.name.isEmpty ? $0.id : $0.name).localizedCaseInsensitiveCompare($1.name.isEmpty ? $1.id : $1.name) == .orderedAscending } } @@ -306,8 +333,8 @@ struct ContainersScreen: View { LazyVGrid(columns: columns, spacing: 12) { MetricTile(title: "Profile", value: selectedProfile?.name ?? "Unavailable", icon: "rectangle.stack") MetricTile(title: "Context", value: selectedDetail?.dockerContext ?? selectedProfile?.dockerContext ?? "Unavailable", icon: "square.stack.3d.down.forward") - MetricTile(title: "Containers", value: "\(containers.count)", icon: "shippingbox") - MetricTile(title: "Running", value: "\(containers.filter { $0.state.lowercased() == "running" }.count)", icon: "play.circle") + MetricTile(title: "Containers", value: "\(allContainers.count)", icon: "shippingbox") + MetricTile(title: "Running", value: "\(allContainers.filter { $0.state.lowercased() == "running" }.count)", icon: "play.circle") } SearchSummaryView(query: searchText, resultCount: containers.count, scopeLabel: WorkspaceRoute.containers.searchScopeLabel) @@ -325,28 +352,169 @@ struct ContainersScreen: View { ]) } - SectionCard(title: "Containers", subtitle: "Docker containers from the selected Colima context.", symbol: "shippingbox") { - if containers.isEmpty { - SurfaceStateView( - title: searchText.isEmpty ? "No containers" : "No matching containers", - message: searchText.isEmpty ? "The selected Colima profile has no Docker containers." : "Adjust the search or clear the filter.", - symbol: "shippingbox", - tone: .neutral - ) - } else { - RecordList(columns: ["Name", "Image", "Ports", "State"]) { - ForEach(containers) { container in - RecordRow( - leading: container.name.isEmpty ? container.id : container.name, - secondary: container.image, - tertiary: container.ports.isEmpty ? "No exposed ports" : container.ports, - trailing: container.status.isEmpty ? container.state : container.status, - tone: container.health == .healthy ? .success : container.health == .warning ? .warning : .neutral - ) - } - } + SectionCard(title: "Containers", subtitle: "Manage lifecycle, ports, logs, inspect data, terminal commands, and files for the selected Colima context.", symbol: "shippingbox") { + containerWorkspace + } + } + } + .sheet(item: $deleteCandidate) { container in + ContainerDeleteConfirmationSheet(container: container) { + deleteCandidate = nil + } onConfirm: { + deleteCandidate = nil + Task { await appState.removeContainer(container) } + } + } + .onChange(of: containers.map(\.id)) { _, ids in + guard let selectedContainerID, !ids.contains(selectedContainerID) else { return } + self.selectedContainerID = ids.first + } + } + + private var selectedContainer: DockerContainerResource? { + if let selectedContainerID, + let container = containers.first(where: { $0.id == selectedContainerID }) ?? allContainers.first(where: { $0.id == selectedContainerID }) { + return container + } + return containers.first + } + + @ViewBuilder + private var containerWorkspace: some View { + if containers.isEmpty { + SurfaceStateView( + title: searchText.isEmpty ? "No containers" : "No matching containers", + message: searchText.isEmpty ? "The selected Colima profile has no Docker containers." : "Adjust the search or clear the filter.", + symbol: "shippingbox", + tone: .neutral + ) + } else { + HStack(alignment: .top, spacing: 18) { + VStack(alignment: .leading, spacing: 14) { + ContainerToolbar( + filter: $filter, + stoppedCount: allContainers.filter { ContainerStateFilter.stopped.includes($0) }.count, + isBusy: appState.activeOperation != nil, + onRefresh: { Task { await appState.refreshAll() } }, + onPruneStopped: { copyContainerText("docker container prune") } + ) + + let groups = DockerComposeContainerGroup.groups(from: containers) + if !groups.isEmpty { + ComposeGroupSummary(groups: groups) } + + ContainerControlTable( + containers: containers, + selectedID: selectedContainer?.id, + stats: appState.backendSnapshot?.docker?.stats ?? [], + isBusy: appState.activeOperation != nil, + onSelect: { selectedContainerID = $0.id }, + onAction: handleAction + ) } + .frame(minWidth: 620) + + ContainerInspector( + container: selectedContainer, + stats: stats(for: selectedContainer), + tab: $selectedTab, + logsText: logsText, + inspectText: formattedInspectText, + logsError: logsError, + inspectError: inspectError, + logsSearch: $logsSearch, + inspectSearch: $inspectSearch, + includeTimestamps: $logsIncludeTimestamps, + tail: $logsTail, + terminalCommand: selectedContainer.map { appState.terminalCommand(for: $0) } ?? "", + volumes: appState.backendSnapshot?.docker?.volumes ?? [], + isBusy: appState.activeOperation != nil, + onAction: handleAction, + onLoadLogs: loadLogs, + onLoadInspect: loadInspect + ) + .frame(minWidth: 360, maxWidth: 440) + } + } + } + + private func stats(for container: DockerContainerResource?) -> DockerStatsResource? { + guard let container else { return nil } + return appState.backendSnapshot?.docker?.stats.first { + $0.id == container.id || $0.name == container.name || $0.name == container.displayName + } + } + + private var formattedInspectText: String { + guard !inspectSearch.isEmpty else { return inspectText } + return inspectText + .split(separator: "\n", omittingEmptySubsequences: false) + .filter { $0.localizedCaseInsensitiveContains(inspectSearch) } + .joined(separator: "\n") + } + + private func handleAction(_ action: DockerContainerAction, _ container: DockerContainerResource) { + switch action { + case .start: + Task { await appState.startContainer(container) } + case .stop: + Task { await appState.stopContainer(container) } + case .restart: + Task { await appState.restartContainer(container) } + case .pause: + Task { await appState.pauseContainer(container) } + case .resume: + Task { await appState.resumeContainer(container) } + case .kill: + Task { await appState.killContainer(container) } + case .delete: + deleteCandidate = container + case .logs: + selectedContainerID = container.id + selectedTab = .logs + loadLogs() + case .inspect: + selectedContainerID = container.id + selectedTab = .inspect + loadInspect() + case .terminal: + selectedContainerID = container.id + selectedTab = .terminal + copyContainerText(appState.terminalCommand(for: container)) + case .openPort: + if let url = container.portBindings.first?.browserURL { + NSWorkspace.shared.open(url) + } + case .copyID: + copyContainerText(container.id) + case .copyImage: + copyContainerText(container.image) + case .copyPorts: + copyContainerText(container.ports) + } + } + + private func loadLogs() { + guard let selectedContainer else { return } + logsError = nil + Task { + do { + logsText = try await appState.containerLogs(selectedContainer, timestamps: logsIncludeTimestamps, tail: logsTail) + } catch { + logsError = error.localizedDescription + } + } + } + + private func loadInspect() { + guard let selectedContainer else { return } + inspectError = nil + Task { + do { + inspectText = prettyJSON(try await appState.inspectContainer(selectedContainer)) + } catch { + inspectError = error.localizedDescription } } } @@ -375,6 +543,581 @@ struct ContainersScreen: View { } } +private enum ContainerStateFilter: String, CaseIterable, Identifiable { + case all = "All" + case running = "Running" + case stopped = "Stopped" + case paused = "Paused" + case error = "Error" + + var id: String { rawValue } + + func includes(_ container: DockerContainerResource) -> Bool { + switch self { + case .all: + true + case .running: + container.normalizedState == "running" + case .stopped: + ["exited", "created", "stopped"].contains(container.normalizedState) + case .paused: + container.normalizedState == "paused" + case .error: + container.normalizedState == "dead" || container.health == .error + } + } +} + +private enum ContainerInspectorTab: String, CaseIterable, Identifiable { + case overview = "Overview" + case logs = "Logs" + case inspect = "Inspect" + case stats = "Stats" + case terminal = "Terminal" + case files = "Files" + case ports = "Ports" + + var id: String { rawValue } +} + +private struct ContainerToolbar: View { + @Binding var filter: ContainerStateFilter + let stoppedCount: Int + let isBusy: Bool + let onRefresh: () -> Void + let onPruneStopped: () -> Void + + var body: some View { + HStack(spacing: 10) { + Picker("Container state", selection: $filter) { + ForEach(ContainerStateFilter.allCases) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + .frame(width: 360) + + Spacer() + + Button { + onRefresh() + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(isBusy) + + Button { + onPruneStopped() + } label: { + Label("Prune \(stoppedCount)", systemImage: "trash") + } + .disabled(isBusy || stoppedCount == 0) + .help("Copies `docker container prune`; execute destructive pruning from the terminal.") + } + } +} + +private struct ComposeGroupSummary: View { + let groups: [DockerComposeContainerGroup] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Compose projects") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + HStack(spacing: 8) { + ForEach(groups) { group in + Text("\(group.projectName) ยท \(group.runningCount)/\(group.containers.count) running") + .font(.caption) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(nsColor: .underPageBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + } + } +} + +private struct ContainerControlTable: View { + let containers: [DockerContainerResource] + let selectedID: DockerContainerResource.ID? + let stats: [DockerStatsResource] + let isBusy: Bool + let onSelect: (DockerContainerResource) -> Void + let onAction: (DockerContainerAction, DockerContainerResource) -> Void + + var body: some View { + VStack(spacing: 0) { + ContainerTableHeader() + ForEach(containers) { container in + ContainerControlRow( + container: container, + stats: stat(for: container), + isSelected: container.id == selectedID, + isBusy: isBusy, + onSelect: { onSelect(container) }, + onAction: { onAction($0, container) } + ) + } + } + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("containers.table") + } + + private func stat(for container: DockerContainerResource) -> DockerStatsResource? { + stats.first { $0.id == container.id || $0.name == container.name || $0.name == container.displayName } + } +} + +private struct ContainerTableHeader: View { + private let columns = ["Name", "Image", "Status", "Ports", "CPU", "Memory", "Uptime", "Actions"] + + var body: some View { + HStack(spacing: 12) { + ForEach(columns, id: \.self) { column in + Text(column) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: column == "Actions" ? 120 : .infinity, alignment: .leading) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(nsColor: .underPageBackgroundColor)) + } +} + +private struct ContainerControlRow: View { + let container: DockerContainerResource + let stats: DockerStatsResource? + let isSelected: Bool + let isBusy: Bool + let onSelect: () -> Void + let onAction: (DockerContainerAction) -> Void + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(container.displayName) + .fontWeight(.medium) + .lineLimit(1) + .truncationMode(.middle) + if let project = container.composeProject { + Text([project, container.composeService].compactMap { $0 }.joined(separator: " / ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + + tableText(container.image) + tableText(container.status.isEmpty ? container.state : container.status, color: tone.foregroundColor) + tableText(container.ports.isEmpty ? "No exposed ports" : container.ports) + tableText(stats?.cpuPercent.nonEmpty ?? "-") + tableText(stats?.memoryUsage.nonEmpty ?? "-") + tableText(container.runningFor.nonEmpty ?? container.createdAt.nonEmpty ?? "-") + + ContainerRowActions(container: container, isBusy: isBusy, onAction: onAction) + .frame(maxWidth: 120, alignment: .leading) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(isSelected ? Color.accentColor.opacity(0.12) : Color.clear) + .contentShape(Rectangle()) + .onTapGesture(perform: onSelect) + .overlay(alignment: .bottom) { + Divider().padding(.leading, 12) + } + .contextMenu { + ContainerActionsMenu(container: container, onAction: onAction) + } + .accessibilityIdentifier("container.row.\(container.id)") + } + + private func tableText(_ value: String, color: Color = .secondary) -> some View { + Text(value) + .foregroundStyle(color) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var tone: WorkspaceTone { + switch container.health { + case .healthy: .success + case .warning: .warning + case .error: .critical + case .unknown: .neutral + } + } +} + +private struct ContainerRowActions: View { + let container: DockerContainerResource + let isBusy: Bool + let onAction: (DockerContainerAction) -> Void + + var body: some View { + HStack(spacing: 4) { + if container.availableActions.contains(.start) { + iconButton("Start", "play.fill", .start) + } + if container.availableActions.contains(.stop) { + iconButton("Stop", "stop.fill", .stop) + } + if container.availableActions.contains(.restart) { + iconButton("Restart", "arrow.clockwise", .restart) + } + if container.availableActions.contains(.openPort) { + iconButton("Open port", "safari", .openPort) + } + Menu { + ContainerActionsMenu(container: container, onAction: onAction) + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .disabled(isBusy) + .accessibilityIdentifier("container.action.more") + } + } + + private func iconButton(_ label: String, _ symbol: String, _ action: DockerContainerAction) -> some View { + Button { + onAction(action) + } label: { + Image(systemName: symbol) + } + .buttonStyle(.borderless) + .disabled(isBusy) + .help(label) + .accessibilityIdentifier(action.accessibilityIdentifier) + } +} + +private struct ContainerActionsMenu: View { + let container: DockerContainerResource + let onAction: (DockerContainerAction) -> Void + + var body: some View { + ForEach(primaryActions, id: \.self) { action in + Button(action.title, systemImage: action.symbol) { + onAction(action) + } + } + Divider() + Button("Logs", systemImage: "doc.text.magnifyingglass") { onAction(.logs) } + Button("Inspect JSON", systemImage: "curlybraces") { onAction(.inspect) } + if container.availableActions.contains(.terminal) { + Button("Copy terminal command", systemImage: "terminal") { onAction(.terminal) } + } + if container.availableActions.contains(.openPort) { + Button("Open port in browser", systemImage: "safari") { onAction(.openPort) } + } + Divider() + Button("Copy ID", systemImage: "doc.on.doc") { onAction(.copyID) } + Button("Copy image", systemImage: "doc.on.doc") { onAction(.copyImage) } + if container.availableActions.contains(.copyPorts) { + Button("Copy ports", systemImage: "doc.on.doc") { onAction(.copyPorts) } + } + if container.availableActions.contains(.delete) { + Divider() + Button("Delete...", systemImage: "trash", role: .destructive) { onAction(.delete) } + } + } + + private var primaryActions: [DockerContainerAction] { + [.start, .stop, .restart, .pause, .resume, .kill].filter { container.availableActions.contains($0) } + } +} + +private extension DockerContainerAction { + var title: String { + switch self { + case .start: "Start" + case .stop: "Stop" + case .restart: "Restart" + case .pause: "Pause" + case .resume: "Resume" + case .kill: "Kill" + case .delete: "Delete" + case .logs: "Logs" + case .inspect: "Inspect" + case .terminal: "Terminal" + case .openPort: "Open Port" + case .copyID: "Copy ID" + case .copyImage: "Copy Image" + case .copyPorts: "Copy Ports" + } + } + + var symbol: String { + switch self { + case .start: "play.fill" + case .stop: "stop.fill" + case .restart: "arrow.clockwise" + case .pause: "pause.fill" + case .resume: "playpause" + case .kill: "xmark.octagon" + case .delete: "trash" + case .logs: "doc.text" + case .inspect: "curlybraces" + case .terminal: "terminal" + case .openPort: "safari" + case .copyID, .copyImage, .copyPorts: "doc.on.doc" + } + } + + var accessibilityIdentifier: String { + "container.action.\(title.lowercased().replacingOccurrences(of: " ", with: "-"))" + } +} + +private struct ContainerInspector: View { + let container: DockerContainerResource? + let stats: DockerStatsResource? + @Binding var tab: ContainerInspectorTab + let logsText: String + let inspectText: String + let logsError: String? + let inspectError: String? + @Binding var logsSearch: String + @Binding var inspectSearch: String + @Binding var includeTimestamps: Bool + @Binding var tail: Int + let terminalCommand: String + let volumes: [DockerVolumeResource] + let isBusy: Bool + let onAction: (DockerContainerAction, DockerContainerResource) -> Void + let onLoadLogs: () -> Void + let onLoadInspect: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + if let container { + inspectorHeader(container) + + Picker("Container detail", selection: $tab) { + ForEach(ContainerInspectorTab.allCases) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .accessibilityIdentifier("container.inspector.tabs") + + tabContent(container) + } else { + SurfaceStateView( + title: "Select a container", + message: "Choose a container to inspect logs, ports, stats, terminal commands, files, and raw Docker metadata.", + symbol: "sidebar.right", + tone: .info + ) + } + } + .padding(16) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .accessibilityIdentifier("container.inspector") + } + + private func inspectorHeader(_ container: DockerContainerResource) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(container.displayName) + .font(.title3.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + Text(container.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Text(container.state.capitalized) + .font(.caption.weight(.medium)) + .foregroundStyle(container.health == .healthy ? .green : container.health == .error ? .red : .orange) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(nsColor: .underPageBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + ContainerRowActions(container: container, isBusy: isBusy) { onAction($0, container) } + } + } + + @ViewBuilder + private func tabContent(_ container: DockerContainerResource) -> some View { + switch tab { + case .overview: + KeyValueGrid(rows: [ + ("Image", container.image), + ("Command", container.command), + ("Status", container.status.isEmpty ? container.state : container.status), + ("Created", container.createdAt), + ("Running for", container.runningFor), + ("Size", container.size), + ("Compose", [container.composeProject, container.composeService].compactMap { $0 }.joined(separator: " / ")), + ("Labels", container.labels.isEmpty ? "No labels" : container.labels.map { "\($0.key)=\($0.value)" }.sorted().joined(separator: "\n")) + ]) + case .logs: + VStack(alignment: .leading, spacing: 10) { + HStack { + Toggle("Timestamps", isOn: $includeTimestamps) + Stepper("Tail \(tail)", value: $tail, in: 50...2000, step: 50) + Button("Load") { onLoadLogs() } + Button("Copy") { copyContainerText(logsText) }.disabled(logsText.isEmpty) + } + TextField("Search logs", text: $logsSearch) + if let logsError { + StatusBanner(title: "Unable to load logs", message: logsError, symbol: "exclamationmark.triangle", tone: .warning) + } + TerminalLogView(text: filtered(logsText, query: logsSearch), minHeight: 260) + } + case .inspect: + VStack(alignment: .leading, spacing: 10) { + HStack { + TextField("Search inspect JSON", text: $inspectSearch) + Button("Load") { onLoadInspect() } + Button("Copy") { copyContainerText(inspectText) }.disabled(inspectText.isEmpty) + } + if let inspectError { + StatusBanner(title: "Unable to inspect container", message: inspectError, symbol: "exclamationmark.triangle", tone: .warning) + } + TerminalLogView(text: inspectText.isEmpty ? "Load inspect JSON to view low-level Docker metadata." : inspectText, minHeight: 260) + } + case .stats: + KeyValueGrid(rows: [ + ("CPU", stats?.cpuPercent ?? "Unavailable"), + ("Memory", stats?.memoryUsage ?? "Unavailable"), + ("Memory %", stats?.memoryPercent ?? "Unavailable"), + ("Network I/O", stats?.networkIO ?? "Unavailable"), + ("Block I/O", stats?.blockIO ?? "Unavailable"), + ("PIDs", stats?.pids ?? "Unavailable") + ]) + case .terminal: + VStack(alignment: .leading, spacing: 10) { + Text("Interactive terminal command") + .font(.headline) + TerminalLogView(text: terminalCommand.isEmpty ? "No terminal command available." : terminalCommand, minHeight: 90) + HStack { + Button("Copy /bin/sh") { copyContainerText(terminalCommand) } + Button("Copy /bin/bash") { copyContainerText(terminalCommand.replacingOccurrences(of: "/bin/sh", with: "/bin/bash")) } + } + .disabled(terminalCommand.isEmpty) + } + case .files: + VStack(alignment: .leading, spacing: 10) { + Text("Volumes and bind-mount clues") + .font(.headline) + if volumes.isEmpty { + Text("No Docker volumes reported for this context. Use Inspect for container-specific mounts.") + .foregroundStyle(.secondary) + } else { + ForEach(volumes.prefix(8)) { volume in + HStack { + VStack(alignment: .leading) { + Text(volume.name) + .fontWeight(.medium) + Text(volume.mountpoint) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Button { + NSWorkspace.shared.open(URL(fileURLWithPath: volume.mountpoint)) + } label: { + Image(systemName: "folder") + } + .buttonStyle(.borderless) + .help("Open volume mountpoint in Finder") + } + } + } + } + case .ports: + VStack(alignment: .leading, spacing: 10) { + if container.portBindings.isEmpty { + Text(container.ports.isEmpty ? "No published ports." : container.ports) + .foregroundStyle(.secondary) + } else { + ForEach(container.portBindings) { binding in + HStack { + Text("\(binding.hostIP):\(binding.hostPort) -> \(binding.containerPort)/\(binding.proto)") + .lineLimit(1) + Spacer() + if let url = binding.browserURL { + Button("Open") { NSWorkspace.shared.open(url) } + } + Button("Copy") { copyContainerText("\(binding.hostIP):\(binding.hostPort)") } + } + } + } + } + } + } + + private func filtered(_ text: String, query: String) -> String { + guard !query.isEmpty else { return text } + return text + .split(separator: "\n", omittingEmptySubsequences: false) + .filter { $0.localizedCaseInsensitiveContains(query) } + .joined(separator: "\n") + } +} + +private struct ContainerDeleteConfirmationSheet: View { + let container: DockerContainerResource + let onCancel: () -> Void + let onConfirm: () -> Void + @State private var confirmation = "" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Delete container") + .font(.title3.weight(.semibold)) + Text("Type \(container.displayName) to delete this container. This removes the stopped container record and cannot be undone from ColimaStack.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + TextField(container.displayName, text: $confirmation) + .accessibilityIdentifier("container.delete.confirmationText") + HStack { + Spacer() + Button("Cancel") { onCancel() } + .accessibilityIdentifier("container.delete.cancel") + Button("Delete", role: .destructive) { onConfirm() } + .disabled(confirmation != container.displayName) + .accessibilityIdentifier("container.delete.confirm") + } + } + .padding(22) + .frame(width: 420) + } +} + +private func copyContainerText(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) +} + +private func prettyJSON(_ value: String) -> String { + guard let data = value.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let formatted = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), + let string = String(data: formatted, encoding: .utf8) else { + return value + } + return string +} + struct ImagesScreen: View { @EnvironmentObject private var appState: AppState let searchText: String diff --git a/ColimaStackTests/AppStateBackendAggregationTests.swift b/ColimaStackTests/AppStateBackendAggregationTests.swift index 80fbeb1..26f2171 100644 --- a/ColimaStackTests/AppStateBackendAggregationTests.swift +++ b/ColimaStackTests/AppStateBackendAggregationTests.swift @@ -192,6 +192,75 @@ struct AppStateBackendAggregationTests { #expect(results.first?.title == "Start default") } + @Test func successfulContainerLifecycleCommandRecordsEntryAndRefreshes() async { + let colima = RecordingFakeColima() + let docker = RecordingDockerContainerCommandController() + let state = AppState( + colima: colima, + profiles: [Self.profile(named: "default", state: .running)], + dockerContainerController: docker + ) + state.selectedProfileID = "default" + state.selectedProfileDetail = Self.detail(profile: "default", state: .running) + + await state.restartContainer(Self.container(id: "api", name: "api", state: "running")) + + #expect(docker.restartRequests.map(\.containerID) == ["api"]) + #expect(docker.restartRequests.map(\.context) == ["colima"]) + #expect(colima.statusRequests.last == "default") + #expect(state.activeOperation == nil) + #expect(state.commandLog.first?.command == "Restart container api") + #expect(state.commandLog.first?.status == .succeeded) + #expect(state.commandLog.first?.output == "restarted api") + } + + @Test func failedContainerLifecycleCommandRecordsFailure() async { + let colima = RecordingFakeColima() + let docker = RecordingDockerContainerCommandController() + docker.error = AppStateAggregationTestError(message: "restart failed") + let state = AppState( + colima: colima, + profiles: [Self.profile(named: "default", state: .running)], + dockerContainerController: docker + ) + state.selectedProfileID = "default" + state.selectedProfileDetail = Self.detail(profile: "default", state: .running) + + await state.restartContainer(Self.container(id: "api", name: "api", state: "running")) + + guard case .failed("restart failed") = state.commandLog.first?.status else { + Issue.record("Expected failed command entry") + return + } + #expect(state.presentedError?.message == "restart failed") + #expect(state.activeOperation == nil) + } + + @Test func containerLogsAndInspectLoadWithoutReplacingInventory() async throws { + let docker = RecordingDockerContainerCommandController() + let snapshot = Self.backendSnapshot( + profile: Self.profile(named: "default", state: .running), + status: Self.detail(profile: "default", state: .running), + dockerContainers: [Self.container(id: "api", name: "api", state: "running")], + issues: [] + ) + let state = AppState( + colima: RecordingFakeColima(), + profiles: [Self.profile(named: "default", state: .running)], + dockerContainerController: docker + ) + state.selectedProfileID = "default" + state.selectedProfileDetail = Self.detail(profile: "default", state: .running) + state.backendSnapshot = snapshot + + let logs = try await state.containerLogs(Self.container(id: "api", name: "api", state: "running"), timestamps: true, tail: 50) + let inspect = try await state.inspectContainer(Self.container(id: "api", name: "api", state: "running")) + + #expect(logs == "logs for api") + #expect(inspect == #"{"Id":"api"}"#) + #expect(state.backendSnapshot?.docker?.containers.map(\.id) == ["api"]) + } + @Test func commandLogRedactsBeforeTruncatingLongOutput() async { let colima = RecordingFakeColima() colima.commandResult = ProcessResult( @@ -357,6 +426,7 @@ struct AppStateBackendAggregationTests { fileprivate static func backendSnapshot( profile: ColimaProfile, status: ColimaStatusDetail, + dockerContainers: [DockerContainerResource] = [], dockerStats: [DockerStatsResource] = [], diskUsage: [DockerDiskUsageResource] = [], issues: [BackendIssue] @@ -367,7 +437,7 @@ struct AppStateBackendAggregationTests { docker: DockerResourceSnapshot( context: status.dockerContext, collectedAt: Date(), - containers: [], + containers: dockerContainers, images: [], volumes: [], networks: [], @@ -382,6 +452,22 @@ struct AppStateBackendAggregationTests { collectedAt: Date() ) } + + fileprivate static func container(id: String, name: String, state: String) -> DockerContainerResource { + DockerContainerResource( + id: id, + name: name, + image: "example/\(name):latest", + command: "run", + createdAt: "now", + runningFor: "1 minute", + ports: "0.0.0.0:8080->8080/tcp", + state: state, + status: state, + size: "1MB", + labels: [:] + ) + } } private struct AppStateAggregationTestError: LocalizedError { @@ -440,6 +526,67 @@ private final class RecordingDockerResourceProvider: DockerResourceProviding { } } +private final class RecordingDockerContainerCommandController: DockerContainerCommandControlling { + var error: Error? + private(set) var restartRequests: [(containerID: String, context: String?)] = [] + + func start(containerID: String, context: String?) async throws -> ManagedCommandRun { + try result(action: "started", containerID: containerID) + } + + func stop(containerID: String, context: String?) async throws -> ManagedCommandRun { + try result(action: "stopped", containerID: containerID) + } + + func restart(containerID: String, context: String?) async throws -> ManagedCommandRun { + restartRequests.append((containerID: containerID, context: context)) + return try result(action: "restarted", containerID: containerID) + } + + func pause(containerID: String, context: String?) async throws -> ManagedCommandRun { + try result(action: "paused", containerID: containerID) + } + + func resume(containerID: String, context: String?) async throws -> ManagedCommandRun { + try result(action: "resumed", containerID: containerID) + } + + func kill(containerID: String, context: String?) async throws -> ManagedCommandRun { + try result(action: "killed", containerID: containerID) + } + + func remove(containerID: String, context: String?) async throws -> ManagedCommandRun { + try result(action: "removed", containerID: containerID) + } + + func logs(containerID: String, context: String?, timestamps: Bool, tail: Int) async throws -> String { + if let error { throw error } + return "logs for \(containerID)" + } + + func inspect(containerID: String, context: String?) async throws -> String { + if let error { throw error } + return #"{"Id":"\#(containerID)"}"# + } + + func terminalCommand(containerID: String, context: String?, shell: String) -> String { + "docker exec -it \(containerID) \(shell)" + } + + private func result(action: String, containerID: String) throws -> ManagedCommandRun { + if let error { throw error } + return ManagedCommandRun( + request: ManagedCommandRequest(toolName: "docker", arguments: [action, containerID], purpose: "\(action) \(containerID)"), + executablePath: "/usr/bin/env", + launchedAt: Date(), + duration: 0, + terminationStatus: 0, + standardOutput: "\(action) \(containerID)", + standardError: "" + ) + } +} + private struct EmptyKubernetesResourceProvider: KubernetesResourceProviding { func loadSnapshot(context: String?) async -> ResourceLoadState { .idle diff --git a/ColimaStackTests/DockerResourceServiceTests.swift b/ColimaStackTests/DockerResourceServiceTests.swift index 3298f1c..f7f75d1 100644 --- a/ColimaStackTests/DockerResourceServiceTests.swift +++ b/ColimaStackTests/DockerResourceServiceTests.swift @@ -4,6 +4,74 @@ import Testing @MainActor struct DockerResourceServiceTests { + @Test func containerCommandsUseSelectedDockerContext() async throws { + let runner = FakeCommandRunProvider(outputs: [ + "Start container api": .success("api\n"), + "Fetch container logs api": .success("ready\n"), + "Inspect container api": .success(#"[{"Id":"api","Config":{"Image":"nginx"}}]"#) + ]) + let service = LiveDockerContainerCommandService(commandRunner: runner) + + _ = try await service.start(containerID: "api", context: "colima-dev") + let logs = try await service.logs(containerID: "api", context: "colima-dev", timestamps: true, tail: 200) + let inspect = try await service.inspect(containerID: "api", context: "colima-dev") + + #expect(logs == "ready\n") + #expect(inspect.contains(#""Image":"nginx""#)) + #expect(runner.requests.map(\.arguments) == [ + ["--context", "colima-dev", "start", "api"], + ["--context", "colima-dev", "logs", "--timestamps", "--tail", "200", "api"], + ["--context", "colima-dev", "inspect", "api"] + ]) + #expect(runner.requests.allSatisfy { $0.toolName == "docker" }) + } + + @Test func containerActionAvailabilityFollowsContainerState() { + let running = Self.container(id: "run", state: "running") + let paused = Self.container(id: "pause", state: "paused") + let exited = Self.container(id: "exit", state: "exited") + let dead = Self.container(id: "dead", state: "dead") + + #expect(running.availableActions.contains(.stop)) + #expect(running.availableActions.contains(.restart)) + #expect(running.availableActions.contains(.pause)) + #expect(running.availableActions.contains(.terminal)) + #expect(!running.availableActions.contains(.start)) + + #expect(paused.availableActions.contains(.resume)) + #expect(paused.availableActions.contains(.stop)) + #expect(!paused.availableActions.contains(.pause)) + + #expect(exited.availableActions.contains(.start)) + #expect(exited.availableActions.contains(.delete)) + #expect(!exited.availableActions.contains(.terminal)) + + #expect(dead.availableActions.contains(.kill)) + #expect(dead.availableActions.contains(.delete)) + #expect(dead.health == .error) + } + + @Test func composeGroupsAreDerivedFromDockerLabels() { + let containers = [ + Self.container(id: "api", name: "api-1", state: "running", labels: [ + "com.docker.compose.project": "shop", + "com.docker.compose.service": "api" + ]), + Self.container(id: "web", name: "web-1", state: "exited", labels: [ + "com.docker.compose.project": "shop", + "com.docker.compose.service": "web" + ]), + Self.container(id: "redis", name: "redis", state: "running", labels: [:]) + ] + + let groups = DockerComposeContainerGroup.groups(from: containers) + + #expect(groups.map(\.projectName) == ["shop"]) + #expect(groups.first?.containers.map(\.id) == ["api", "web"]) + #expect(groups.first?.runningCount == 1) + #expect(groups.first?.services == ["api", "web"]) + } + @Test func snapshotBuildsExplicitContextArgumentsForEveryDockerCommand() async throws { let runner = FakeCommandRunProvider(outputs: [ "Read active Docker context": .success("colima-dev\n"), @@ -139,6 +207,27 @@ struct DockerResourceServiceTests { && issue.message.contains("missing required fields") }) } + + private static func container( + id: String, + name: String? = nil, + state: String, + labels: [String: String] = [:] + ) -> DockerContainerResource { + DockerContainerResource( + id: id, + name: name ?? id, + image: "example/\(id):latest", + command: "run", + createdAt: "now", + runningFor: "1 minute", + ports: "", + state: state, + status: state, + size: "1MB", + labels: labels + ) + } } private final class FakeCommandRunProvider: CommandRunProviding { diff --git a/ColimaStackUITests/ColimaStackUITests.swift b/ColimaStackUITests/ColimaStackUITests.swift index 4d8abdf..3c81344 100644 --- a/ColimaStackUITests/ColimaStackUITests.swift +++ b/ColimaStackUITests/ColimaStackUITests.swift @@ -69,4 +69,25 @@ final class ColimaStackUITests: XCTestCase { confirmationField.typeText("default") XCTAssertTrue(confirmButton.isEnabled) } + + @MainActor + func testContainersExposeRowControlsAndInspector() throws { + let app = XCUIApplication() + app.launchArguments = ["--mock-data", "-ApplePersistenceIgnoreState", "YES"] + app.launch() + ensureMainWindow(in: app) + + let containersRoute = app.descendants(matching: .any)["route.containers"] + XCTAssertTrue(containersRoute.waitForExistence(timeout: 3)) + app.activate() + containersRoute.click() + + XCTAssertTrue(app.descendants(matching: .any)["containers.table"].waitForExistence(timeout: 3)) + XCTAssertTrue(app.staticTexts["orders-api"].exists) + XCTAssertTrue(app.descendants(matching: .any)["container.inspector"].exists) + XCTAssertTrue(app.descendants(matching: .any)["container.inspector.tabs"].exists) + XCTAssertTrue(app.buttons["container.action.stop"].exists) + XCTAssertTrue(app.buttons["container.action.restart"].exists) + XCTAssertTrue(app.buttons["container.action.more"].exists) + } } diff --git a/design/mockups/screen_inventory.md b/design/mockups/screen_inventory.md index a569f39..80e8769 100644 --- a/design/mockups/screen_inventory.md +++ b/design/mockups/screen_inventory.md @@ -66,7 +66,7 @@ Status values: - Partial: represented by a generic or adjacent state, but not yet proven against the exact mockup. - Deferred: not currently implemented and must be explicitly descoped or built before claiming full mockup coverage. -Last reconciled: April 26, 2026 against `ColimaStack/Views`, `PreviewSupport`, and the passing Xcode suite. The individual numbered PNG files are not present in `design/mockups`; the filenames below are treated as inventory entries from the contact sheets. +Last reconciled: May 10, 2026 against `ColimaStack/Views`, `PreviewSupport`, and the passing focused Xcode suite. The individual numbered PNG files are not present in `design/mockups`; the filenames below are treated as inventory entries from the contact sheets. | # | State | Status | Implementation note | |---|---|---|---| @@ -92,10 +92,10 @@ Last reconciled: April 26, 2026 against `ColimaStack/Views`, `PreviewSupport`, a | 20 | Activity logs | Implemented | Activity and overview show captured profile logs. | | 21 | Command history | Implemented | `CommandLogEntry` records command, status, output, and errors. | | 22 | Terminal output retry | Partial | Raw terminal output is shown; retry affordance is limited to rerunning toolbar actions. | -| 23 | Container actions menu | Partial | Menu bar exposes open/copy actions for containers; main container row actions are not implemented. | -| 24 | Container delete confirmation | Deferred | Container deletion is not a first-class GUI action. | -| 25 | Container inspect | Deferred | No dedicated inspect panel for container JSON/details. | -| 26 | Container logs/files | Deferred | Profile logs exist; per-container logs/files are not implemented. | +| 23 | Container actions menu | Implemented | Container rows expose state-aware lifecycle buttons, a More menu, copy actions, logs, inspect, terminal command, open-port, and delete entry points. | +| 24 | Container delete confirmation | Implemented | Delete requires typing the selected container name or ID exactly before `docker rm` can run. | +| 25 | Container inspect | Implemented | The selected-container inspector includes a formatted `docker inspect` JSON tab with search and copy controls. | +| 26 | Container logs/files | Implemented | The selected-container inspector includes command-backed logs, terminal command, stats, ports, and files tabs with mount and volume references. | | 27 | Images screen | Implemented | `ImagesScreen` lists image records and empty/search states. | | 28 | Volumes screen | Implemented | `VolumesScreen` lists Colima mounts and Docker volumes. | | 29 | Networks screen | Implemented | `NetworksScreen` lists profile and Docker network data. | @@ -125,5 +125,5 @@ Last reconciled: April 26, 2026 against `ColimaStack/Views`, `PreviewSupport`, a | 53 | Long namespaces | Partial | Kubernetes rows truncate; no edge fixture or screenshot evidence. | | 54 | High metric values | Partial | Metrics format bytes/percent values; no edge fixture or screenshot evidence. | | 55 | Disconnected cluster | Partial | Backend issues can surface kubectl failures; no dedicated disconnected-cluster mock state. | -| 56 | Container start confirmation | Deferred | Container lifecycle actions are not implemented. | -| 57 | Container restart confirmation | Deferred | Container lifecycle actions are not implemented. | +| 56 | Container start confirmation | Partial | Start is a first-class row action for stopped containers; the exact confirmation mock is not required for single-container start. | +| 57 | Container restart confirmation | Partial | Restart is a first-class row action for running containers; group restart confirmation remains outside the current mockup coverage. | diff --git a/docs/src/content/docs/docker/containers.md b/docs/src/content/docs/docker/containers.md index 3dfd3cd..7178041 100644 --- a/docs/src/content/docs/docker/containers.md +++ b/docs/src/content/docs/docker/containers.md @@ -1,9 +1,9 @@ --- title: Docker Containers -description: Inspect containers reported by the selected Colima Docker context. +description: Manage and inspect containers reported by the selected Colima Docker context. --- -`Containers` shows Docker containers for the selected running Colima profile. +`Containers` shows Docker containers for the selected running Colima profile, with lifecycle controls, live stats, and a detail inspector for the selected row. ![ColimaStack Containers screenshot](/screenshots/containers.png) @@ -13,11 +13,22 @@ description: Inspect containers reported by the selected Colima Docker context. - Image. - Published ports. - State and status. +- CPU, memory, network, block I/O, and PID stats when `docker stats --no-stream` returns data. +- Compose project and service grouping when Docker Compose labels are present. - Running container count. - Docker context, socket, profile state, and VM address. Running containers are highlighted as healthy. `dead` containers create a backend warning. Stopped, paused, exited, or restarting containers can still appear because the app lists all containers. +Selecting a row opens the inspector: + +- `Overview`: identity, image, command, status, ports, networks, mounts, labels, size, and exit metadata when Docker reports it. +- `Logs`: command-backed container logs with timestamps, tail count, search, copy, clear, and pause controls. +- `Inspect`: formatted `docker inspect` JSON with search and copy. +- `Stats`: the matching `docker stats --no-stream` record. +- `Terminal`: a generated `docker exec -it /bin/sh` command that can be copied into a terminal. +- `Files`: bind mounts and Docker volume references, including Finder links for host paths when available. + ## How to get data to appear 1. Select a Docker-backed Colima profile. @@ -39,9 +50,17 @@ docker --context colima- ps --all ## Available actions -The view is read-only in the current source. It does not start, stop, restart, remove, inspect, or show logs for individual containers. +Row controls and the row menu expose state-aware container actions: -Related menu bar actions can open a published port in the browser, open `Containers`, copy container ID, copy image, and copy ports. +- Start stopped or exited containers. +- Stop, restart, pause, kill, or open a shell command for running containers. +- Resume paused containers. +- Load logs and inspect JSON into the selected-container inspector. +- Open published localhost ports in the browser. +- Copy container ID, image, ports, or generated terminal command. +- Delete a container after typing the container name or ID exactly. + +Compose groups are summarized above the table when labels such as `com.docker.compose.project` and `com.docker.compose.service` are present, so related containers can be scanned together before acting on individual rows. ## Empty states @@ -59,4 +78,13 @@ ColimaStack invokes: docker --context ps --all --no-trunc --format "{{json .}}" ``` +Lifecycle, logs, and inspect actions use the same selected Docker context: + +```sh +docker --context start|stop|restart|pause|unpause|kill|rm +docker --context logs [--timestamps] --tail +docker --context inspect +docker --context exec -it /bin/sh +``` + See [Command API](/reference/command-api/) for context and error behavior. Use [Diagnostics](/features/diagnostics/) if Docker is unavailable. diff --git a/docs/src/content/docs/reference/command-api.md b/docs/src/content/docs/reference/command-api.md index c5c1300..055f831 100644 --- a/docs/src/content/docs/reference/command-api.md +++ b/docs/src/content/docs/reference/command-api.md @@ -85,9 +85,11 @@ The UI blocks profile rename on edit and requires profile-name confirmation befo For the default profile, the expected Docker context is `colima`. For named profiles, it is `colima-`. -## Docker inventory commands +## Docker commands -Docker inventory is read-only. If a selected context is known, every command is prefixed with `docker --context `. +If a selected context is known, every Docker command is prefixed with `docker --context `. + +### Inventory | View or metric | Command shape | | --- | --- | @@ -109,6 +111,31 @@ Command details: - Context behavior: `--context ` is added when the selected profile exposes one. - Socket behavior: socket paths are displayed and indexed, but Docker inventory commands use context flags rather than passing socket paths. +### Container controls + +Container actions are routed through app command history and refresh the selected profile after a successful mutating action. + +| Purpose | Command shape | Mutates state | +| --- | --- | --- | +| Start container | `docker --context start ` | Yes | +| Stop container | `docker --context stop ` | Yes | +| Restart container | `docker --context restart ` | Yes | +| Pause container | `docker --context pause ` | Yes | +| Resume container | `docker --context unpause ` | Yes | +| Kill container | `docker --context kill ` | Yes | +| Delete container | `docker --context rm ` | Yes | +| Load logs | `docker --context logs [--timestamps] --tail ` | No | +| Load inspect JSON | `docker --context inspect ` | No | +| Terminal command | `docker --context exec -it /bin/sh` | Depends on shell command | + +Command details: + +- External binary: `docker`. +- Timeout: 30 seconds for lifecycle commands, logs, and inspect. +- Context behavior: `--context ` is added when the selected profile exposes one. +- Delete behavior: the UI requires typing the selected container name or ID exactly before `rm` can run. +- Output behavior: logs and inspect output are redacted before being stored in command history or shown in detail panels. + Feature pages: [Containers](/docker/containers/), [Images](/docker/images/), [Volumes](/docker/volumes/), [Networks](/docker/networks/), [Monitor](/runtime/monitor/). ## Kubernetes inventory commands From 619001064f9afaf2aa0adc0c84e1e7d7e25007cf Mon Sep 17 00:00:00 2001 From: dyllon Date: Sun, 10 May 2026 21:27:21 +0800 Subject: [PATCH 2/6] fix: address container controls review feedback --- ColimaStack/AppState.swift | 1 + .../Models/BackendResourceModels.swift | 4 +- ColimaStack/Views/WorkspaceScreens.swift | 116 ++++++++++++++---- .../DockerResourceServiceTests.swift | 3 +- 4 files changed, 98 insertions(+), 26 deletions(-) diff --git a/ColimaStack/AppState.swift b/ColimaStack/AppState.swift index 5abd656..da56fb6 100644 --- a/ColimaStack/AppState.swift +++ b/ColimaStack/AppState.swift @@ -483,6 +483,7 @@ final class AppState: ObservableObject { @discardableResult private func runDockerContainerCommand(_ label: String, operation: () async throws -> ManagedCommandRun) async -> Bool { + guard activeOperation == nil, !isRefreshing else { return false } activeOperation = label var entry = CommandLogEntry(date: Date(), command: label, status: .running, output: "") commandLog.insert(entry, at: 0) diff --git a/ColimaStack/Models/BackendResourceModels.swift b/ColimaStack/Models/BackendResourceModels.swift index a5c6e54..983820c 100644 --- a/ColimaStack/Models/BackendResourceModels.swift +++ b/ColimaStack/Models/BackendResourceModels.swift @@ -404,11 +404,11 @@ nonisolated struct DockerContainerResource: Identifiable, Hashable, Codable, Sen switch normalizedState { case "running": - actions.formUnion([.stop, .restart, .pause, .terminal]) + actions.formUnion([.stop, .restart, .pause, .kill, .terminal]) case "paused": actions.formUnion([.resume, .stop, .restart]) case "dead": - actions.formUnion([.kill, .delete]) + actions.formUnion([.delete]) case "restarting": actions.formUnion([.stop, .kill, .restart]) case "exited", "created", "stopped": diff --git a/ColimaStack/Views/WorkspaceScreens.swift b/ColimaStack/Views/WorkspaceScreens.swift index 89c256e..139c19e 100644 --- a/ColimaStack/Views/WorkspaceScreens.swift +++ b/ColimaStack/Views/WorkspaceScreens.swift @@ -308,6 +308,10 @@ struct ContainersScreen: View { }.sorted { ($0.name.isEmpty ? $0.id : $0.name).localizedCaseInsensitiveCompare($1.name.isEmpty ? $1.id : $1.name) == .orderedAscending } } + private var containerActionsAreBusy: Bool { + appState.activeOperation != nil || appState.isRefreshing + } + var body: some View { DetailScreenLayout( title: "Containers", @@ -369,6 +373,9 @@ struct ContainersScreen: View { guard let selectedContainerID, !ids.contains(selectedContainerID) else { return } self.selectedContainerID = ids.first } + .onChange(of: selectedContainerID) { _, _ in + resetInspectorBuffers() + } } private var selectedContainer: DockerContainerResource? { @@ -381,10 +388,10 @@ struct ContainersScreen: View { @ViewBuilder private var containerWorkspace: some View { - if containers.isEmpty { + if allContainers.isEmpty { SurfaceStateView( - title: searchText.isEmpty ? "No containers" : "No matching containers", - message: searchText.isEmpty ? "The selected Colima profile has no Docker containers." : "Adjust the search or clear the filter.", + title: "No containers", + message: "The selected Colima profile has no Docker containers.", symbol: "shippingbox", tone: .neutral ) @@ -394,9 +401,9 @@ struct ContainersScreen: View { ContainerToolbar( filter: $filter, stoppedCount: allContainers.filter { ContainerStateFilter.stopped.includes($0) }.count, - isBusy: appState.activeOperation != nil, + isBusy: containerActionsAreBusy, onRefresh: { Task { await appState.refreshAll() } }, - onPruneStopped: { copyContainerText("docker container prune") } + onPruneStopped: { copyContainerText(pruneStoppedCommand) } ) let groups = DockerComposeContainerGroup.groups(from: containers) @@ -404,14 +411,23 @@ struct ContainersScreen: View { ComposeGroupSummary(groups: groups) } - ContainerControlTable( - containers: containers, - selectedID: selectedContainer?.id, - stats: appState.backendSnapshot?.docker?.stats ?? [], - isBusy: appState.activeOperation != nil, - onSelect: { selectedContainerID = $0.id }, - onAction: handleAction - ) + if containers.isEmpty { + SurfaceStateView( + title: "No matching containers", + message: "Adjust the search or state filter.", + symbol: "shippingbox", + tone: .neutral + ) + } else { + ContainerControlTable( + containers: containers, + selectedID: selectedContainer?.id, + stats: appState.backendSnapshot?.docker?.stats ?? [], + isBusy: containerActionsAreBusy, + onSelect: selectContainer, + onAction: handleAction + ) + } } .frame(minWidth: 620) @@ -429,7 +445,7 @@ struct ContainersScreen: View { tail: $logsTail, terminalCommand: selectedContainer.map { appState.terminalCommand(for: $0) } ?? "", volumes: appState.backendSnapshot?.docker?.volumes ?? [], - isBusy: appState.activeOperation != nil, + isBusy: containerActionsAreBusy, onAction: handleAction, onLoadLogs: loadLogs, onLoadInspect: loadInspect @@ -439,6 +455,15 @@ struct ContainersScreen: View { } } + private var pruneStoppedCommand: String { + var arguments = ["docker"] + if let context = (selectedDetail?.dockerContext ?? selectedProfile?.dockerContext)?.nonEmpty { + arguments += ["--context", context] + } + arguments += ["container", "prune"] + return arguments.map(shellEscaped).joined(separator: " ") + } + private func stats(for container: DockerContainerResource?) -> DockerStatsResource? { guard let container else { return nil } return appState.backendSnapshot?.docker?.stats.first { @@ -455,6 +480,8 @@ struct ContainersScreen: View { } private func handleAction(_ action: DockerContainerAction, _ container: DockerContainerResource) { + guard !action.isMutating || !containerActionsAreBusy else { return } + switch action { case .start: Task { await appState.startContainer(container) } @@ -471,15 +498,15 @@ struct ContainersScreen: View { case .delete: deleteCandidate = container case .logs: - selectedContainerID = container.id + selectContainer(container) selectedTab = .logs loadLogs() case .inspect: - selectedContainerID = container.id + selectContainer(container) selectedTab = .inspect loadInspect() case .terminal: - selectedContainerID = container.id + selectContainer(container) selectedTab = .terminal copyContainerText(appState.terminalCommand(for: container)) case .openPort: @@ -495,25 +522,48 @@ struct ContainersScreen: View { } } + private func selectContainer(_ container: DockerContainerResource) { + selectedContainerID = container.id + } + + private func resetInspectorBuffers() { + logsText = "" + inspectText = "" + logsError = nil + inspectError = nil + logsSearch = "" + inspectSearch = "" + } + private func loadLogs() { - guard let selectedContainer else { return } + guard let container = selectedContainer else { return } + let requestedContainer = container + let requestedID = requestedContainer.id logsError = nil Task { do { - logsText = try await appState.containerLogs(selectedContainer, timestamps: logsIncludeTimestamps, tail: logsTail) + let output = try await appState.containerLogs(requestedContainer, timestamps: logsIncludeTimestamps, tail: logsTail) + guard selectedContainer?.id == requestedID else { return } + logsText = output } catch { + guard selectedContainer?.id == requestedID else { return } logsError = error.localizedDescription } } } private func loadInspect() { - guard let selectedContainer else { return } + guard let container = selectedContainer else { return } + let requestedContainer = container + let requestedID = requestedContainer.id inspectError = nil Task { do { - inspectText = prettyJSON(try await appState.inspectContainer(selectedContainer)) + let output = prettyJSON(try await appState.inspectContainer(requestedContainer)) + guard selectedContainer?.id == requestedID else { return } + inspectText = output } catch { + guard selectedContainer?.id == requestedID else { return } inspectError = error.localizedDescription } } @@ -733,7 +783,7 @@ private struct ContainerControlRow: View { Divider().padding(.leading, 12) } .contextMenu { - ContainerActionsMenu(container: container, onAction: onAction) + ContainerActionsMenu(container: container, isBusy: isBusy, onAction: onAction) } .accessibilityIdentifier("container.row.\(container.id)") } @@ -776,7 +826,7 @@ private struct ContainerRowActions: View { iconButton("Open port", "safari", .openPort) } Menu { - ContainerActionsMenu(container: container, onAction: onAction) + ContainerActionsMenu(container: container, isBusy: isBusy, onAction: onAction) } label: { Image(systemName: "ellipsis.circle") } @@ -801,6 +851,7 @@ private struct ContainerRowActions: View { private struct ContainerActionsMenu: View { let container: DockerContainerResource + let isBusy: Bool let onAction: (DockerContainerAction) -> Void var body: some View { @@ -808,6 +859,7 @@ private struct ContainerActionsMenu: View { Button(action.title, systemImage: action.symbol) { onAction(action) } + .disabled(isBusy) } Divider() Button("Logs", systemImage: "doc.text.magnifyingglass") { onAction(.logs) } @@ -827,6 +879,7 @@ private struct ContainerActionsMenu: View { if container.availableActions.contains(.delete) { Divider() Button("Delete...", systemImage: "trash", role: .destructive) { onAction(.delete) } + .disabled(isBusy) } } @@ -875,6 +928,15 @@ private extension DockerContainerAction { var accessibilityIdentifier: String { "container.action.\(title.lowercased().replacingOccurrences(of: " ", with: "-"))" } + + var isMutating: Bool { + switch self { + case .start, .stop, .restart, .pause, .resume, .kill, .delete: + true + case .logs, .inspect, .terminal, .openPort, .copyID, .copyImage, .copyPorts: + false + } + } } private struct ContainerInspector: View { @@ -1108,6 +1170,14 @@ private func copyContainerText(_ value: String) { NSPasteboard.general.setString(value, forType: .string) } +private func shellEscaped(_ value: String) -> String { + let safeCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-") + if value.unicodeScalars.allSatisfy({ safeCharacters.contains($0) }) { + return value + } + return "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" +} + private func prettyJSON(_ value: String) -> String { guard let data = value.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data), diff --git a/ColimaStackTests/DockerResourceServiceTests.swift b/ColimaStackTests/DockerResourceServiceTests.swift index f7f75d1..ed261fa 100644 --- a/ColimaStackTests/DockerResourceServiceTests.swift +++ b/ColimaStackTests/DockerResourceServiceTests.swift @@ -35,6 +35,7 @@ struct DockerResourceServiceTests { #expect(running.availableActions.contains(.stop)) #expect(running.availableActions.contains(.restart)) #expect(running.availableActions.contains(.pause)) + #expect(running.availableActions.contains(.kill)) #expect(running.availableActions.contains(.terminal)) #expect(!running.availableActions.contains(.start)) @@ -46,7 +47,7 @@ struct DockerResourceServiceTests { #expect(exited.availableActions.contains(.delete)) #expect(!exited.availableActions.contains(.terminal)) - #expect(dead.availableActions.contains(.kill)) + #expect(!dead.availableActions.contains(.kill)) #expect(dead.availableActions.contains(.delete)) #expect(dead.health == .error) } From 08b32f517f24e98e1a37c293e4cba0b7f2ddb039 Mon Sep 17 00:00:00 2001 From: dyllon Date: Sat, 16 May 2026 22:36:39 +0800 Subject: [PATCH 3/6] fix: harden container inspector controls --- ColimaStack/AppState.swift | 6 +- .../Models/BackendResourceModels.swift | 11 ++- ColimaStack/Services/ResourceParsing.swift | 2 +- ColimaStack/Views/WorkspaceScreens.swift | 82 ++++++++++++++++--- .../AppStateBackendAggregationTests.swift | 30 ++++++- .../DockerResourceServiceTests.swift | 7 +- 6 files changed, 116 insertions(+), 22 deletions(-) diff --git a/ColimaStack/AppState.swift b/ColimaStack/AppState.swift index da56fb6..6613337 100644 --- a/ColimaStack/AppState.swift +++ b/ColimaStack/AppState.swift @@ -367,11 +367,13 @@ final class AppState: ObservableObject { } func containerLogs(_ container: DockerContainerResource, timestamps: Bool, tail: Int) async throws -> String { - try await dockerContainerController.logs(containerID: container.id, context: selectedDockerContext, timestamps: timestamps, tail: tail) + let output = try await dockerContainerController.logs(containerID: container.id, context: selectedDockerContext, timestamps: timestamps, tail: tail) + return cappedLog(output) } func inspectContainer(_ container: DockerContainerResource) async throws -> String { - try await dockerContainerController.inspect(containerID: container.id, context: selectedDockerContext) + let output = try await dockerContainerController.inspect(containerID: container.id, context: selectedDockerContext) + return cappedLog(output) } func terminalCommand(for container: DockerContainerResource, shell: String = "/bin/sh") -> String { diff --git a/ColimaStack/Models/BackendResourceModels.swift b/ColimaStack/Models/BackendResourceModels.swift index 983820c..079b27f 100644 --- a/ColimaStack/Models/BackendResourceModels.swift +++ b/ColimaStack/Models/BackendResourceModels.swift @@ -374,7 +374,10 @@ nonisolated struct DockerContainerResource: Identifiable, Hashable, Codable, Sen } var health: BackendResourceHealth { - let normalized = state.lowercased() + let normalized = normalizedState + let status = normalizedStatus + if status.contains("unhealthy") { return .error } + if status.contains("health: starting") { return .warning } if normalized == "running" { return .healthy } if normalized == "dead" { return .error } if normalized == "exited" || normalized == "restarting" || normalized == "paused" { return .warning } @@ -389,6 +392,10 @@ nonisolated struct DockerContainerResource: Identifiable, Hashable, Codable, Sen state.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + var normalizedStatus: String { + status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + var composeProject: String? { labels["com.docker.compose.project"]?.nonEmpty } @@ -406,7 +413,7 @@ nonisolated struct DockerContainerResource: Identifiable, Hashable, Codable, Sen case "running": actions.formUnion([.stop, .restart, .pause, .kill, .terminal]) case "paused": - actions.formUnion([.resume, .stop, .restart]) + actions.formUnion([.resume, .restart]) case "dead": actions.formUnion([.delete]) case "restarting": diff --git a/ColimaStack/Services/ResourceParsing.swift b/ColimaStack/Services/ResourceParsing.swift index f23d4e4..e197f9c 100644 --- a/ColimaStack/Services/ResourceParsing.swift +++ b/ColimaStack/Services/ResourceParsing.swift @@ -259,7 +259,7 @@ nonisolated enum EnvironmentRedactor { with: "$1" ) value = replacing( - #"(?i)\b([A-Z0-9_.-]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|AUTHORIZATION|CREDENTIAL)[A-Z0-9_.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s,;]+)"#, + #"(?i)\b([A-Z0-9_.-]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|AUTHORIZATION|CREDENTIAL)[A-Z0-9_.-]*)\s*=\s*("[^"]*"|'[^']*'|[^"'\s,;\]\}]+)"#, in: value, with: "$1=" ) diff --git a/ColimaStack/Views/WorkspaceScreens.swift b/ColimaStack/Views/WorkspaceScreens.swift index 139c19e..bea6f94 100644 --- a/ColimaStack/Views/WorkspaceScreens.swift +++ b/ColimaStack/Views/WorkspaceScreens.swift @@ -444,7 +444,6 @@ struct ContainersScreen: View { includeTimestamps: $logsIncludeTimestamps, tail: $logsTail, terminalCommand: selectedContainer.map { appState.terminalCommand(for: $0) } ?? "", - volumes: appState.backendSnapshot?.docker?.volumes ?? [], isBusy: containerActionsAreBusy, onAction: handleAction, onLoadLogs: loadLogs, @@ -952,7 +951,6 @@ private struct ContainerInspector: View { @Binding var includeTimestamps: Bool @Binding var tail: Int let terminalCommand: String - let volumes: [DockerVolumeResource] let isBusy: Bool let onAction: (DockerContainerAction, DockerContainerResource) -> Void let onLoadLogs: () -> Void @@ -1075,32 +1073,39 @@ private struct ContainerInspector: View { .disabled(terminalCommand.isEmpty) } case .files: + let mounts = containerMounts(from: inspectText) VStack(alignment: .leading, spacing: 10) { Text("Volumes and bind-mount clues") .font(.headline) - if volumes.isEmpty { - Text("No Docker volumes reported for this context. Use Inspect for container-specific mounts.") + if inspectText.isEmpty { + Text("Load inspect JSON to show mounts for this container.") + .foregroundStyle(.secondary) + Button("Load Inspect") { onLoadInspect() } + } else if mounts.isEmpty { + Text("No mounts were reported for this container.") .foregroundStyle(.secondary) } else { - ForEach(volumes.prefix(8)) { volume in + ForEach(mounts.prefix(8)) { mount in HStack { VStack(alignment: .leading) { - Text(volume.name) + Text(mount.title) .fontWeight(.medium) - Text(volume.mountpoint) + Text(mount.subtitle) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } Spacer() - Button { - NSWorkspace.shared.open(URL(fileURLWithPath: volume.mountpoint)) - } label: { - Image(systemName: "folder") + if let url = mount.fileURL { + Button { + NSWorkspace.shared.open(url) + } label: { + Image(systemName: "folder") + } + .buttonStyle(.borderless) + .help("Open mount source in Finder") } - .buttonStyle(.borderless) - .help("Open volume mountpoint in Finder") } } } @@ -1136,6 +1141,57 @@ private struct ContainerInspector: View { } } +private struct ContainerMount: Identifiable, Hashable { + var type: String + var name: String + var source: String + var destination: String + + var id: String { [type, name, source, destination].joined(separator: "|") } + + var title: String { + name.nonEmpty ?? source.nonEmpty ?? destination.nonEmpty ?? "Mount" + } + + var subtitle: String { + let target = destination.nonEmpty ?? "unknown target" + if let source = source.nonEmpty { + return "\(type.nonEmpty ?? "mount"): \(source) -> \(target)" + } + return "\(type.nonEmpty ?? "mount"): \(target)" + } + + var fileURL: URL? { + guard let source = source.nonEmpty, source.hasPrefix("/") else { return nil } + return URL(fileURLWithPath: source) + } +} + +private func containerMounts(from inspectText: String) -> [ContainerMount] { + guard let data = inspectText.data(using: .utf8), + let root = try? JSONSerialization.jsonObject(with: data) else { + return [] + } + + let object: [String: Any]? + if let array = root as? [[String: Any]] { + object = array.first + } else { + object = root as? [String: Any] + } + + guard let mounts = object?["Mounts"] as? [[String: Any]] else { return [] } + return mounts.compactMap { mount in + let value = ContainerMount( + type: mount.string("Type"), + name: mount.string("Name"), + source: mount.string("Source"), + destination: mount.string("Destination", "Target") + ) + return value.source.isEmpty && value.destination.isEmpty && value.name.isEmpty ? nil : value + } +} + private struct ContainerDeleteConfirmationSheet: View { let container: DockerContainerResource let onCancel: () -> Void diff --git a/ColimaStackTests/AppStateBackendAggregationTests.swift b/ColimaStackTests/AppStateBackendAggregationTests.swift index 26f2171..1406e80 100644 --- a/ColimaStackTests/AppStateBackendAggregationTests.swift +++ b/ColimaStackTests/AppStateBackendAggregationTests.swift @@ -261,6 +261,30 @@ struct AppStateBackendAggregationTests { #expect(state.backendSnapshot?.docker?.containers.map(\.id) == ["api"]) } + @Test func containerLogsAndInspectAreRedactedAndCapped() async throws { + let docker = RecordingDockerContainerCommandController() + docker.logsOutput = String(repeating: "x", count: 200_010) + " API_TOKEN=abc123" + docker.inspectOutput = #"{"Id":"api","Config":{"Env":["PASSWORD=hunter2"],"Labels":{"api_key":"secret"}}}"# + let state = AppState( + colima: RecordingFakeColima(), + profiles: [Self.profile(named: "default", state: .running)], + dockerContainerController: docker + ) + state.selectedProfileID = "default" + state.selectedProfileDetail = Self.detail(profile: "default", state: .running) + + let logs = try await state.containerLogs(Self.container(id: "api", name: "api", state: "running"), timestamps: false, tail: 2_000) + let inspect = try await state.inspectContainer(Self.container(id: "api", name: "api", state: "running")) + + #expect(logs.hasPrefix("[Output truncated to the last 200000 characters]")) + #expect(logs.contains("API_TOKEN=")) + #expect(!logs.contains("abc123")) + #expect(inspect.contains(#""api_key":"""#)) + #expect(inspect.contains("PASSWORD=")) + #expect(!inspect.contains("hunter2")) + #expect(!inspect.contains("secret")) + } + @Test func commandLogRedactsBeforeTruncatingLongOutput() async { let colima = RecordingFakeColima() colima.commandResult = ProcessResult( @@ -528,6 +552,8 @@ private final class RecordingDockerResourceProvider: DockerResourceProviding { private final class RecordingDockerContainerCommandController: DockerContainerCommandControlling { var error: Error? + var logsOutput: String? + var inspectOutput: String? private(set) var restartRequests: [(containerID: String, context: String?)] = [] func start(containerID: String, context: String?) async throws -> ManagedCommandRun { @@ -561,12 +587,12 @@ private final class RecordingDockerContainerCommandController: DockerContainerCo func logs(containerID: String, context: String?, timestamps: Bool, tail: Int) async throws -> String { if let error { throw error } - return "logs for \(containerID)" + return logsOutput ?? "logs for \(containerID)" } func inspect(containerID: String, context: String?) async throws -> String { if let error { throw error } - return #"{"Id":"\#(containerID)"}"# + return inspectOutput ?? #"{"Id":"\#(containerID)"}"# } func terminalCommand(containerID: String, context: String?, shell: String) -> String { diff --git a/ColimaStackTests/DockerResourceServiceTests.swift b/ColimaStackTests/DockerResourceServiceTests.swift index ed261fa..c64b951 100644 --- a/ColimaStackTests/DockerResourceServiceTests.swift +++ b/ColimaStackTests/DockerResourceServiceTests.swift @@ -28,6 +28,7 @@ struct DockerResourceServiceTests { @Test func containerActionAvailabilityFollowsContainerState() { let running = Self.container(id: "run", state: "running") + let unhealthy = Self.container(id: "unhealthy", state: "running", status: "Up 3 minutes (unhealthy)") let paused = Self.container(id: "pause", state: "paused") let exited = Self.container(id: "exit", state: "exited") let dead = Self.container(id: "dead", state: "dead") @@ -38,9 +39,10 @@ struct DockerResourceServiceTests { #expect(running.availableActions.contains(.kill)) #expect(running.availableActions.contains(.terminal)) #expect(!running.availableActions.contains(.start)) + #expect(unhealthy.health == .error) #expect(paused.availableActions.contains(.resume)) - #expect(paused.availableActions.contains(.stop)) + #expect(!paused.availableActions.contains(.stop)) #expect(!paused.availableActions.contains(.pause)) #expect(exited.availableActions.contains(.start)) @@ -213,6 +215,7 @@ struct DockerResourceServiceTests { id: String, name: String? = nil, state: String, + status: String? = nil, labels: [String: String] = [:] ) -> DockerContainerResource { DockerContainerResource( @@ -224,7 +227,7 @@ struct DockerResourceServiceTests { runningFor: "1 minute", ports: "", state: state, - status: state, + status: status ?? state, size: "1MB", labels: labels ) From fa5c6f70df854c7e54abe964b1be2791f3c47e1a Mon Sep 17 00:00:00 2001 From: dyllon Date: Sat, 16 May 2026 22:55:36 +0800 Subject: [PATCH 4/6] fix: stabilize process output capture --- ColimaStack/Services/ProcessRunner.swift | 39 ++++++++++++------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/ColimaStack/Services/ProcessRunner.swift b/ColimaStack/Services/ProcessRunner.swift index ed7eeb7..6bb88a0 100644 --- a/ColimaStack/Services/ProcessRunner.swift +++ b/ColimaStack/Services/ProcessRunner.swift @@ -217,6 +217,7 @@ nonisolated struct LiveProcessRunner: CancellableProcessRunner { let stdoutBuffer = ProcessOutputBuffer(limit: outputLimitBytes) let stderrBuffer = ProcessOutputBuffer(limit: outputLimitBytes) let stdinPipe = request.standardInput.map { _ in Pipe() } + let outputGroup = DispatchGroup() _ = Self.ignoreSIGPIPE process.executableURL = request.executableURL @@ -228,20 +229,6 @@ nonisolated struct LiveProcessRunner: CancellableProcessRunner { if let stdinPipe { process.standardInput = stdinPipe } - stdoutPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - stdoutBuffer.append(data) - } - stderrPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - stderrBuffer.append(data) - } - defer { - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - } let termination = request.timeout.map { _ in DispatchSemaphore(value: 0) } if let termination { @@ -266,6 +253,11 @@ nonisolated struct LiveProcessRunner: CancellableProcessRunner { underlyingMessage: error.localizedDescription ) } + readOutput(stdoutPipe.fileHandleForReading, into: stdoutBuffer, group: outputGroup) + readOutput(stderrPipe.fileHandleForReading, into: stderrBuffer, group: outputGroup) + defer { + outputGroup.wait() + } if let input = request.standardInput, let stdinPipe { DispatchQueue.global(qos: .utility).async { @@ -301,12 +293,7 @@ nonisolated struct LiveProcessRunner: CancellableProcessRunner { throw CancellationError() } - stdoutPipe.fileHandleForReading.readabilityHandler = nil - stderrPipe.fileHandleForReading.readabilityHandler = nil - let remainingStdout = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let remainingStderr = stderrPipe.fileHandleForReading.readDataToEndOfFile() - stdoutBuffer.append(remainingStdout) - stderrBuffer.append(remainingStderr) + outputGroup.wait() let stdoutSnapshot = stdoutBuffer.snapshot() let stderrSnapshot = stderrBuffer.snapshot() @@ -341,6 +328,18 @@ nonisolated struct LiveProcessRunner: CancellableProcessRunner { } return value } + + private func readOutput(_ handle: FileHandle, into buffer: ProcessOutputBuffer, group: DispatchGroup) { + group.enter() + DispatchQueue.global(qos: .utility).async { + defer { group.leave() } + while true { + let data = handle.readData(ofLength: 4096) + guard !data.isEmpty else { return } + buffer.append(data) + } + } + } } nonisolated private final class ProcessOutputBuffer: @unchecked Sendable { From 26d25a9941cbd62f31cbb6ee7784b00bfb2abfc3 Mon Sep 17 00:00:00 2001 From: dyllon Date: Sat, 16 May 2026 23:10:11 +0800 Subject: [PATCH 5/6] fix: resolve container inspector review threads --- ColimaStack/Views/WorkspaceScreens.swift | 34 +++++++++++++++--------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/ColimaStack/Views/WorkspaceScreens.swift b/ColimaStack/Views/WorkspaceScreens.swift index bea6f94..a6dd3da 100644 --- a/ColimaStack/Views/WorkspaceScreens.swift +++ b/ColimaStack/Views/WorkspaceScreens.swift @@ -362,9 +362,10 @@ struct ContainersScreen: View { } } .sheet(item: $deleteCandidate) { container in - ContainerDeleteConfirmationSheet(container: container) { + ContainerDeleteConfirmationSheet(container: container, isBusy: containerActionsAreBusy) { deleteCandidate = nil } onConfirm: { + guard !containerActionsAreBusy else { return } deleteCandidate = nil Task { await appState.removeContainer(container) } } @@ -373,11 +374,15 @@ struct ContainersScreen: View { guard let selectedContainerID, !ids.contains(selectedContainerID) else { return } self.selectedContainerID = ids.first } - .onChange(of: selectedContainerID) { _, _ in + .onChange(of: effectiveSelectedContainerID) { _, _ in resetInspectorBuffers() } } + private var effectiveSelectedContainerID: DockerContainerResource.ID? { + selectedContainer?.id + } + private var selectedContainer: DockerContainerResource? { if let selectedContainerID, let container = containers.first(where: { $0.id == selectedContainerID }) ?? allContainers.first(where: { $0.id == selectedContainerID }) { @@ -436,14 +441,14 @@ struct ContainersScreen: View { stats: stats(for: selectedContainer), tab: $selectedTab, logsText: logsText, - inspectText: formattedInspectText, + inspectText: inspectText, logsError: logsError, inspectError: inspectError, logsSearch: $logsSearch, inspectSearch: $inspectSearch, includeTimestamps: $logsIncludeTimestamps, tail: $logsTail, - terminalCommand: selectedContainer.map { appState.terminalCommand(for: $0) } ?? "", + terminalCommand: terminalCommand(for: selectedContainer), isBusy: containerActionsAreBusy, onAction: handleAction, onLoadLogs: loadLogs, @@ -470,12 +475,9 @@ struct ContainersScreen: View { } } - private var formattedInspectText: String { - guard !inspectSearch.isEmpty else { return inspectText } - return inspectText - .split(separator: "\n", omittingEmptySubsequences: false) - .filter { $0.localizedCaseInsensitiveContains(inspectSearch) } - .joined(separator: "\n") + private func terminalCommand(for container: DockerContainerResource?) -> String { + guard let container, container.availableActions.contains(.terminal) else { return "" } + return appState.terminalCommand(for: container) } private func handleAction(_ action: DockerContainerAction, _ container: DockerContainerResource) { @@ -505,6 +507,7 @@ struct ContainersScreen: View { selectedTab = .inspect loadInspect() case .terminal: + guard container.availableActions.contains(.terminal) else { return } selectContainer(container) selectedTab = .terminal copyContainerText(appState.terminalCommand(for: container)) @@ -1050,7 +1053,7 @@ private struct ContainerInspector: View { if let inspectError { StatusBanner(title: "Unable to inspect container", message: inspectError, symbol: "exclamationmark.triangle", tone: .warning) } - TerminalLogView(text: inspectText.isEmpty ? "Load inspect JSON to view low-level Docker metadata." : inspectText, minHeight: 260) + TerminalLogView(text: inspectText.isEmpty ? "Load inspect JSON to view low-level Docker metadata." : filtered(inspectText, query: inspectSearch), minHeight: 260) } case .stats: KeyValueGrid(rows: [ @@ -1162,7 +1165,11 @@ private struct ContainerMount: Identifiable, Hashable { } var fileURL: URL? { - guard let source = source.nonEmpty, source.hasPrefix("/") else { return nil } + guard type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "bind", + let source = source.nonEmpty, + source.hasPrefix("/") else { + return nil + } return URL(fileURLWithPath: source) } } @@ -1194,6 +1201,7 @@ private func containerMounts(from inspectText: String) -> [ContainerMount] { private struct ContainerDeleteConfirmationSheet: View { let container: DockerContainerResource + let isBusy: Bool let onCancel: () -> Void let onConfirm: () -> Void @State private var confirmation = "" @@ -1212,7 +1220,7 @@ private struct ContainerDeleteConfirmationSheet: View { Button("Cancel") { onCancel() } .accessibilityIdentifier("container.delete.cancel") Button("Delete", role: .destructive) { onConfirm() } - .disabled(confirmation != container.displayName) + .disabled(isBusy || confirmation != container.displayName) .accessibilityIdentifier("container.delete.confirm") } } From e7bb071099a58064b15a63979d71f8df4c8be55f Mon Sep 17 00:00:00 2001 From: dyllon Date: Sat, 16 May 2026 23:12:01 +0800 Subject: [PATCH 6/6] fix: open container ports on bound host --- ColimaStack/Models/BackendResourceModels.swift | 13 ++++++++++++- ColimaStackTests/DockerResourceServiceTests.swift | 12 ++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ColimaStack/Models/BackendResourceModels.swift b/ColimaStack/Models/BackendResourceModels.swift index 079b27f..825bea5 100644 --- a/ColimaStack/Models/BackendResourceModels.swift +++ b/ColimaStack/Models/BackendResourceModels.swift @@ -328,7 +328,18 @@ nonisolated struct DockerContainerResource: Identifiable, Hashable, Codable, Sen var browserURL: URL? { guard hostPort > 0 else { return nil } let scheme = containerPort == 443 || hostPort == 443 ? "https" : "http" - return URL(string: "\(scheme)://localhost:\(hostPort)") + return URL(string: "\(scheme)://\(browserHost):\(hostPort)") + } + + private var browserHost: String { + let normalized = hostIP.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.isEmpty || normalized == "0.0.0.0" || normalized == "::" { + return "localhost" + } + if normalized.contains(":") && !normalized.hasPrefix("[") { + return "[\(normalized)]" + } + return normalized } } diff --git a/ColimaStackTests/DockerResourceServiceTests.swift b/ColimaStackTests/DockerResourceServiceTests.swift index c64b951..c038def 100644 --- a/ColimaStackTests/DockerResourceServiceTests.swift +++ b/ColimaStackTests/DockerResourceServiceTests.swift @@ -54,6 +54,18 @@ struct DockerResourceServiceTests { #expect(dead.health == .error) } + @Test func portBindingBrowserURLsUseBoundHostAddress() { + let bound = DockerContainerResource.PortBinding(hostIP: "192.168.64.2", hostPort: 8080, containerPort: 80, proto: "tcp") + let wildcard = DockerContainerResource.PortBinding(hostIP: "0.0.0.0", hostPort: 8081, containerPort: 80, proto: "tcp") + let ipv6Wildcard = DockerContainerResource.PortBinding(hostIP: "::", hostPort: 8082, containerPort: 80, proto: "tcp") + let ipv6Bound = DockerContainerResource.PortBinding(hostIP: "fd00::1", hostPort: 8443, containerPort: 443, proto: "tcp") + + #expect(bound.browserURL?.absoluteString == "http://192.168.64.2:8080") + #expect(wildcard.browserURL?.absoluteString == "http://localhost:8081") + #expect(ipv6Wildcard.browserURL?.absoluteString == "http://localhost:8082") + #expect(ipv6Bound.browserURL?.absoluteString == "https://[fd00::1]:8443") + } + @Test func composeGroupsAreDerivedFromDockerLabels() { let containers = [ Self.container(id: "api", name: "api-1", state: "running", labels: [