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
13 changes: 13 additions & 0 deletions App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,19 @@ final class AppState {
}
}

// MARK: - Container detail / exec

func detail(_ id: String) async -> ContainerDetail? {
do { return try await containerEngine.detail(id: id) }
catch { lastError = describe(error); return nil }
}

/// Open an interactive shell into the container in Terminal.
func execShell(_ id: String) {
let binary = AppState.storedPath("containerBinaryPath") ?? "/usr/local/bin/container"
ContainerShell.openShell(binaryPath: binary, id: id)
}

// MARK: - Networks & volumes

func loadInfra() async {
Expand Down
6 changes: 6 additions & 0 deletions App/ConsaiApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,11 @@ struct ConsaiApp: App {
.preferredColorScheme(.dark).tint(Theme.jade)
}
.defaultSize(width: 720, height: 460)

WindowGroup(id: "detail", for: String.self) { $id in
DetailWindow(containerID: id ?? "")
.environment(appState)
}
.defaultSize(width: 480, height: 420)
}
}
7 changes: 7 additions & 0 deletions App/Shots/MockEngines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ struct MockContainerEngine: ContainerEngine {
func delete(id: String) async throws {}
func memoryUsage(id: String) async -> UInt64? { containers.first { $0.id == id }?.memoryBytes }
func cpuUsage(id: String) async -> UInt64? { nil } // cpu% needs sampling; mocks preset cpuPercent
func detail(id: String) async throws -> ContainerDetail {
ContainerDetail(id: id, image: containers.first { $0.id == id }?.image ?? "img",
command: "sleep 3600", env: ["PATH=/usr/bin", "TZ=UTC"],
ports: [PortBinding(host: 8080, container: 80, proto: "tcp")],
mounts: [MountBinding(source: "/data", destination: "/var/data")],
startedAt: nil)
}
}

struct MockComposeEngine: ComposeEngine {
Expand Down
1 change: 1 addition & 0 deletions App/Views/ContainerRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ struct ContainerRow: View {
iconButton("play.fill", "Start") { Task { await appState.start(container.id) } }
}
iconButton("doc.text", "Logs") { openWindow(id: "logs", value: container.id) }
iconButton("info.circle", "Details") { openWindow(id: "detail", value: container.id) }
iconButton("trash", "Delete") { confirmingDelete = true }
}

Expand Down
88 changes: 88 additions & 0 deletions App/Views/DetailWindow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import SwiftUI
import ConsaiCore
import AppKit

/// Opens an interactive shell into a container via Terminal.
enum ContainerShell {
static func openShell(binaryPath: String, id: String) {
// Refuse ids that aren't plain container names — they're interpolated into an
// AppleScript/shell `do script`, so anything exotic is a command-injection risk.
guard isValidContainerName(id) else { return }
let command = containerExecCommand(binary: binaryPath, id: id)
// Escape for AppleScript string literal (defense-in-depth).
let escaped = command.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let script = "tell application \"Terminal\"\nactivate\ndo script \"\(escaped)\"\nend tell"
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
proc.arguments = ["-e", script]
try? proc.run()
}
}

/// Per-container detail: image, command, started, env, ports, mounts; open a shell or logs.
struct DetailWindow: View {
@Environment(AppState.self) private var appState
@Environment(\.openWindow) private var openWindow
let containerID: String

@State private var detail: ContainerDetail?
@State private var loading = true

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if loading {
ProgressView().padding(24)
} else if let detail {
field("Image", detail.image)
field("Command", detail.command.isEmpty ? "—" : detail.command)
if let started = detail.startedAt {
field("Started", started.formatted(date: .abbreviated, time: .shortened))
}
listSection("ENVIRONMENT", detail.env.isEmpty ? ["—"] : detail.env)
listSection("PORTS", detail.ports.isEmpty ? ["—"] : detail.ports.map { "\($0.host) → \($0.container)/\($0.proto)" })
listSection("MOUNTS", detail.mounts.isEmpty ? ["—"] : detail.mounts.map { "\($0.source) → \($0.destination)" })
} else {
EmptyState(symbol: "questionmark.circle", title: "No detail", subtitle: "Couldn't load this container.")
}
}
.padding(.bottom, 10)
}
.frame(minWidth: 460, minHeight: 360)
.background(Theme.bg)
.preferredColorScheme(.dark).tint(Theme.jade)
.navigationTitle(containerID)
.toolbar {
ToolbarItemGroup {
Button { appState.execShell(containerID) } label: { Label("Shell", systemImage: "terminal") }
Button { openWindow(id: "logs", value: containerID) } label: { Label("Logs", systemImage: "doc.text") }
}
}
.onAppear {
NSApp.activate(ignoringOtherApps: true)
Task { detail = await appState.detail(containerID); loading = false }
}
}

private func field(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased()).font(Theme.sectionLabel).tracking(1.5).foregroundStyle(Theme.dim2)
Text(value).font(Theme.mono(11)).foregroundStyle(Theme.text).textSelection(.enabled)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.vertical, 7)
}

private func listSection(_ title: String, _ items: [String]) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(Theme.sectionLabel).tracking(1.5).foregroundStyle(Theme.dim2)
ForEach(items, id: \.self) { item in
Text(item).font(Theme.mono(11)).foregroundStyle(Theme.dim).textSelection(.enabled)
.lineLimit(1).truncationMode(.middle)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16).padding(.vertical, 7)
}
}
2 changes: 2 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public protocol ContainerEngine: Sendable {
func memoryUsage(id: String) async -> UInt64?
/// Best-effort cumulative CPU time in microseconds (for delta-based CPU%); nil if unavailable.
func cpuUsage(id: String) async -> UInt64?
/// Full inspect data (env, ports, mounts, started) for one container.
func detail(id: String) async throws -> ContainerDetail
}

/// Orchestrates compose stacks by shelling out to the `container-compose` CLI.
Expand Down
22 changes: 22 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/SDKContainerEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ public struct SDKContainerEngine: ContainerEngine {
try? await ContainerClient().stats(id: id).cpuUsageUsec
}

public func detail(id: String) async throws -> ContainerDetail {
do {
let snapshot = try await ContainerClient().get(id: id)
let config = snapshot.configuration
let command = ([config.initProcess.executable] + config.initProcess.arguments)
.filter { !$0.isEmpty }.joined(separator: " ")
return ContainerDetail(
id: snapshot.id,
image: config.image.reference,
command: command,
env: config.initProcess.environment.sorted(),
ports: config.publishedPorts.map {
PortBinding(host: Int($0.hostPort), container: Int($0.containerPort), proto: $0.proto.rawValue)
},
mounts: config.mounts.map { MountBinding(source: $0.source, destination: $0.destination) },
startedAt: snapshot.startedDate
)
} catch {
throw ConsaiError.sdk(String(describing: error))
}
}

// MARK: - Mapping (SDK → Consai)

/// In this SDK the container id *is* its display name (`configuration.id`).
Expand Down
51 changes: 51 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Models/ContainerDetail.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

public struct PortBinding: Sendable, Hashable, Identifiable {
public var id: String { "\(host):\(container)/\(proto)" }
public let host: Int
public let container: Int
public let proto: String
public init(host: Int, container: Int, proto: String) {
self.host = host; self.container = container; self.proto = proto
}
}

public struct MountBinding: Sendable, Hashable, Identifiable {
public var id: String { "\(source)->\(destination)" }
public let source: String
public let destination: String
public init(source: String, destination: String) {
self.source = source; self.destination = destination
}
}

/// Full inspect data for one container (from `ContainerClient.get`).
public struct ContainerDetail: Sendable {
public let id: String
public let image: String
public let command: String
public let env: [String] // "KEY=VALUE"
public let ports: [PortBinding]
public let mounts: [MountBinding]
public let startedAt: Date?

public init(id: String, image: String, command: String, env: [String],
ports: [PortBinding], mounts: [MountBinding], startedAt: Date?) {
self.id = id; self.image = image; self.command = command
self.env = env; self.ports = ports; self.mounts = mounts; self.startedAt = startedAt
}
}

/// The command to open an interactive shell in a container. Pure for testing.
public func containerExecCommand(binary: String, id: String, shell: String = "sh") -> String {
"\(binary) exec -it \(id) \(shell)"
}

/// Whether a container name/id is safe to interpolate into a shell/AppleScript command.
/// Apple container names are LDH-style (alphanumeric + `-`/`.`/`_`); anything else (spaces,
/// quotes, `;`, `$`, backticks, newlines, …) is rejected to prevent command injection.
public func isValidContainerName(_ name: String) -> Bool {
!name.isEmpty
&& name.count <= 128
&& name.range(of: "^[A-Za-z0-9][A-Za-z0-9_.-]*$", options: .regularExpression) != nil
}
30 changes: 30 additions & 0 deletions ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,36 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable {
}
}

@Suite struct ContainerDetailTests {
@Test func execCommandBuildsInteractiveShell() {
#expect(containerExecCommand(binary: "/usr/local/bin/container", id: "web")
== "/usr/local/bin/container exec -it web sh")
#expect(containerExecCommand(binary: "container", id: "db", shell: "bash")
== "container exec -it db bash")
}

@Test func validatesContainerNamesAndRejectsInjection() {
#expect(isValidContainerName("shop-api"))
#expect(isValidContainerName("my_db.1"))
#expect(!isValidContainerName(""))
#expect(!isValidContainerName("-leadingdash")) // must start alphanumeric
// Injection attempts must be rejected before reaching the shell/AppleScript.
#expect(!isValidContainerName("web; rm -rf /"))
#expect(!isValidContainerName("a$(whoami)"))
#expect(!isValidContainerName("a\"; do shell script \"x"))
#expect(!isValidContainerName("a b"))
#expect(!isValidContainerName("a\nb"))
}
}

@Suite struct FormatBytesTests {
@Test func formatsMBAndGB() {
#expect(formatBytes(38 * 1_048_576) == "38 MB")
#expect(formatBytes(1024 * 1_048_576) == "1.0 GB")
#expect(formatBytes(0) == "0 MB")
}
}

@Suite struct CLIServiceHealthTests {
@Test func parsesNegativeSignalsAsStopped() {
#expect(CLIServiceHealth.parseStatus(exitCode: 0, output: "apiserver is not running") == .stopped)
Expand Down
Loading