Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions ColimaStack/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -321,6 +324,62 @@ 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 {
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 {
let output = try await dockerContainerController.inspect(containerID: container.id, context: selectedDockerContext)
return cappedLog(output)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve parseable inspect JSON for mounts

When docker inspect returns more than maxLogCharacters (for example a container with very large env/label metadata), this replaces the JSON with a truncation banner plus a suffix before loadInspect stores it. That string is no longer valid JSON, so containerMounts(from: inspectText) in the Files tab cannot parse Mounts and misleadingly reports no mounts; keep a raw/parsed inspect payload for mount extraction and cap only the displayed text.

Useful? React with 👍 / 👎.

}

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") {
Expand Down Expand Up @@ -370,6 +429,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
Expand Down Expand Up @@ -420,6 +483,30 @@ 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)
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
Expand Down
108 changes: 106 additions & 2 deletions ColimaStack/Models/BackendResourceModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down Expand Up @@ -374,12 +385,105 @@ 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 }
return .unknown
}

var displayName: String {
name.isEmpty ? id : name
}

var normalizedState: String {
state.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}

var normalizedStatus: String {
status.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<DockerContainerAction> {
var actions: Set<DockerContainerAction> = [.logs, .inspect, .copyID, .copyImage]
if !ports.isEmpty { actions.insert(.copyPorts) }
if !portBindings.isEmpty { actions.insert(.openPort) }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide open-port actions for stopped containers

Because .openPort is inserted before checking the container state, any stopped/exited/created container that still reports a published port binding gets an Open Port button/menu item. In that state Docker is not running the container process, so opening the retained host mapping sends users to a dead endpoint; only add this action for states where the container can actually serve the port, such as running.

Useful? React with 👍 / 👎.


switch normalizedState {
case "running":
actions.formUnion([.stop, .restart, .pause, .kill, .terminal])
case "paused":
actions.formUnion([.resume, .restart])
case "dead":
actions.formUnion([.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 {
Expand Down
104 changes: 104 additions & 0 deletions ColimaStack/Services/DockerResourceService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading