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
6 changes: 6 additions & 0 deletions sources/dockz/container-config-builder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ struct RunContainerForm {
var labelsText = "" // one KEY=VALUE per line
var restartPolicy = "no"
var network = ""
/// Networks joined in addition to `network` (create API takes one; the
/// store connects these right after create, before start).
var extraNetworks: [String] = []
var privileged = false
var memoryMiB = "" // empty = unlimited
var cpus = "" // empty = unlimited
Expand Down Expand Up @@ -135,6 +138,9 @@ enum ContainerConfigBuilder {
} ?? "no"
let mode = hostConfig["NetworkMode"] as? String ?? ""
form.network = (mode == "default" || mode == "bridge") ? "" : mode
let joined = ((inspect["NetworkSettings"] as? [String: Any])?["Networks"] as? [String: Any]) ?? [:]
let primary = form.network.isEmpty ? "bridge" : form.network
form.extraNetworks = joined.keys.sorted().filter { $0 != primary }
form.privileged = hostConfig["Privileged"] as? Bool ?? false
let memory = hostConfig["Memory"] as? Int ?? 0
form.memoryMiB = memory > 0 ? String(memory / (1024 * 1024)) : ""
Expand Down
17 changes: 15 additions & 2 deletions sources/dockz/container-detail-model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ struct ContainerDetail {
var id: String { containerPort + hostBinding }
}

/// One entry of NetworkSettings.Networks — a container can join several.
struct JoinedNetwork: Identifiable {
let name: String
let ipAddress: String
var id: String { name }
}

let name: String
let image: String
let state: String
Expand All @@ -28,6 +35,7 @@ struct ContainerDetail {
let labels: [String: String]
let mounts: [Mount]
let ports: [PortBinding]
let networks: [JoinedNetwork]

init(dict: [String: Any]) {
let config = dict["Config"] as? [String: Any] ?? [:]
Expand All @@ -48,9 +56,14 @@ struct ContainerDetail {
workingDir = config["WorkingDir"] as? String ?? ""
restartPolicy = ((hostConfig["RestartPolicy"] as? [String: Any])?["Name"] as? String) ?? "no"

let joined = (network["Networks"] as? [String: [String: Any]] ?? [:])
networks = joined.keys.sorted().map {
JoinedNetwork(name: $0, ipAddress: joined[$0]?["IPAddress"] as? String ?? "")
}

var ip = network["IPAddress"] as? String ?? ""
if ip.isEmpty, let networks = network["Networks"] as? [String: [String: Any]] {
ip = networks.values.compactMap { $0["IPAddress"] as? String }.first(where: { !$0.isEmpty }) ?? ""
if ip.isEmpty {
ip = networks.first(where: { !$0.ipAddress.isEmpty })?.ipAddress ?? ""
}
ipAddress = ip

Expand Down
38 changes: 38 additions & 0 deletions sources/dockz/container-detail-view.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ struct ContainerDetailView: View {
}
}
}
networksSection(detail)
if !detail.labels.isEmpty {
Section("Labels") {
ForEach(detail.labels.keys.sorted(), id: \.self) { key in
Expand All @@ -132,6 +133,43 @@ struct ContainerDetailView: View {
.formStyle(.grouped)
}

/// A container can belong to several docker networks at once; joining and
/// leaving take effect live (`docker network connect/disconnect`).
private func networksSection(_ detail: ContainerDetail) -> some View {
Section("Networks") {
ForEach(detail.networks) { network in
HStack {
Label(network.name, systemImage: "network")
Spacer()
Text(network.ipAddress.isEmpty ? "—" : network.ipAddress)
.font(.callout.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
Button("Disconnect") {
store.disconnectNetwork(network.name, container: container)
}
.controlSize(.small)
// The last network would leave the container unreachable.
.disabled(detail.networks.count == 1)
.help(detail.networks.count == 1
? "A container needs at least one network"
: "Leave \(network.name)")
}
}
let joined = Set(detail.networks.map(\.name))
let available = store.networks.map(\.name)
.filter { !joined.contains($0) && $0 != "none" && $0 != "host" }
if !available.isEmpty {
Menu("Connect to network…") {
ForEach(available, id: \.self) { name in
Button(name) { store.connectNetwork(name, container: container) }
}
}
.frame(maxWidth: 220)
}
}
}

private var mountsTab: some View {
List(store.containerDetail?.mounts ?? []) { mount in
VStack(alignment: .leading, spacing: 2) {
Expand Down
5 changes: 4 additions & 1 deletion sources/dockz/dashboard-store-create.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ extension DashboardStore {
busyIDs.insert("run-container")
let config = ContainerConfigBuilder.buildCreateConfig(form)
let auth = pullAuthHeader(forImageRef: form.image)
api.createAndStartContainer(name: form.name, config: config, pullAuthHeader: auth) { [weak self] errorMessage in
let primary = form.network.isEmpty ? "bridge" : form.network
let extras = form.extraNetworks.filter { $0 != primary }
api.createAndStartContainer(name: form.name, config: config, pullAuthHeader: auth,
extraNetworks: extras) { [weak self] errorMessage in
DispatchQueue.main.async {
guard let self else { return }
self.busyIDs.remove("run-container")
Expand Down
14 changes: 14 additions & 0 deletions sources/dockz/dashboard-store-detail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ extension DashboardStore {
selectedContainer = nil
}

// MARK: - Network membership (multi-network containers)

func connectNetwork(_ networkName: String, container: ContainerSummary) {
run(busyKey: container.id) { api, done in
api.connectNetwork(networkName, containerID: container.id, completion: done)
}
}

func disconnectNetwork(_ networkName: String, container: ContainerSummary) {
run(busyKey: container.id) { api, done in
api.disconnectNetwork(networkName, containerID: container.id, completion: done)
}
}

func reloadDetail() {
guard let api = apiProvider(), let container = selectedContainer else { return }
let id = container.id
Expand Down
8 changes: 7 additions & 1 deletion sources/dockz/dashboard-store-edit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ extension DashboardStore {
finish("Edit not applied (old container untouched): \(createError ?? "create failed")")
return
}
let primary = form.network.trimmingCharacters(in: .whitespaces)
let extras = form.extraNetworks.filter { $0 != (primary.isEmpty ? "bridge" : primary) }
api.connectNetworks(extras, containerID: newID) { networkWarning in
api.removeContainer(id: payload.id) { removeError in
if let removeError {
api.removeContainer(id: newID) { _ in }
Expand All @@ -72,12 +75,15 @@ extension DashboardStore {
api.containerAction("start", id: newID) { startError in
if let renameError {
finish("Recreated as \(temporaryName) — \(renameError)")
} else if let startError {
finish("Recreated but failed to start: \(startError)")
} else {
finish(startError.map { "Recreated but failed to start: \($0)" })
finish(networkWarning)
}
}
}
}
}
}
}
}
Expand Down
28 changes: 27 additions & 1 deletion sources/dockz/docker-api-create.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,41 @@ extension DockerAPIClient {
name: String?,
config: [String: Any],
pullAuthHeader: String? = nil,
extraNetworks: [String] = [],
completion: @escaping (String?) -> Void
) {
createContainer(name: name, config: config, pullAuthHeader: pullAuthHeader) { [weak self] id, errorMessage in
guard let id else {
completion(errorMessage ?? "create failed")
return
}
self?.containerAction("start", id: id, completion: completion)
// Joining while still stopped means every interface is up from the
// container's first instant — same as compose with several networks.
self?.connectNetworks(extraNetworks, containerID: id) { connectError in
self?.containerAction("start", id: id) { startError in
completion(startError ?? connectError)
}
}
}
}

/// Connects the container to each network in order; reports the first
/// failure but still attempts the rest (missing one network should not
/// strand the container off the others).
func connectNetworks(_ networks: [String], containerID: String,
completion: @escaping (String?) -> Void) {
var remaining = networks.filter { !$0.isEmpty }
func next(_ firstError: String?) {
guard !remaining.isEmpty else {
completion(firstError)
return
}
let network = remaining.removeFirst()
connectNetwork(network, containerID: containerID) { error in
next(firstError ?? error.map { "connect \(network): \($0)" })
}
}
next(nil)
}

func renameContainer(id: String, to name: String, completion: @escaping (String?) -> Void) {
Expand Down
27 changes: 27 additions & 0 deletions sources/dockz/docker-api-management.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,33 @@ extension DockerAPIClient {
expectSuccess(method: "POST", path: "/volumes/prune", completion: completion)
}

// MARK: - Container ↔ network membership (a container can join several)

func connectNetwork(_ networkName: String, containerID: String, completion: @escaping (String?) -> Void) {
networkMembership("connect", network: networkName, containerID: containerID, completion: completion)
}

func disconnectNetwork(_ networkName: String, containerID: String, completion: @escaping (String?) -> Void) {
networkMembership("disconnect", network: networkName, containerID: containerID, completion: completion)
}

private func networkMembership(_ verb: String, network: String, containerID: String,
completion: @escaping (String?) -> Void) {
postJSON(path: "/networks/\(network)/\(verb)", json: ["Container": containerID]) { result in
switch result {
case .failure(let error):
completion(error.localizedDescription)
case .success(let response):
if (200..<300).contains(response.status) {
completion(nil)
} else {
let message = (try? JSONSerialization.jsonObject(with: response.body) as? [String: Any])?["message"] as? String
completion(message ?? "HTTP \(response.status)")
}
}
}
}

func fetchLogs(id: String, completion: @escaping (String) -> Void) {
requestData(method: "GET", path: "/containers/\(id)/logs?stdout=true&stderr=true&tail=400") { result in
guard case .success(let response) = result else {
Expand Down
22 changes: 22 additions & 0 deletions sources/dockz/launch-at-login.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation
import ServiceManagement

/// Start DockZ when the user logs in, via SMAppService (macOS 13+). The system
/// owns the state — no preference file to keep in sync; `isEnabled` reads the
/// live registration status. The user can also manage it in
/// System Settings → General → Login Items.
enum LaunchAtLogin {
static var isEnabled: Bool {
SMAppService.mainApp.status == .enabled
}

/// Registration can require user approval (macOS shows a notification and
/// the item appears in Login Items); .requiresApproval is not an error here.
static func set(_ enabled: Bool) throws {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
}
}
110 changes: 110 additions & 0 deletions sources/dockz/network-multi-pick-list.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import SwiftUI

/// Multi-select list of docker networks for the run/edit form. Checkboxes in a
/// plain stack stop scaling past a handful of networks, so this renders a
/// bounded, scrollable, filterable pick-list instead: full-row click toggles,
/// a filter field appears once the list is long, and selections stay visible
/// even when filtered out (chips row).
struct NetworkMultiPickList: View {
/// Network names offered for joining (already excludes primary/host/none).
let choices: [String]
/// name → driver, shown as a trailing badge (bridge, overlay, …).
let drivers: [String: String]
@Binding var selection: [String]

@State private var filter = ""

/// Below this many rows the filter field is just noise.
private let filterThreshold = 6
private let rowHeight: CGFloat = 24

private var visible: [String] {
let query = filter.trimmingCharacters(in: .whitespaces).lowercased()
guard !query.isEmpty else { return choices }
return choices.filter { $0.lowercased().contains(query) }
}

var body: some View {
VStack(alignment: .leading, spacing: 6) {
if choices.count >= filterThreshold {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Filter \(choices.count) networks", text: $filter)
.textFieldStyle(.plain)
if !selection.isEmpty {
Button("Clear \(selection.count)") { selection = [] }
.buttonStyle(.borderless)
.controlSize(.small)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.05)))
}

ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(visible, id: \.self) { name in
row(name)
}
if visible.isEmpty {
Text("No network matches “\(filter)”")
.font(.caption)
.foregroundStyle(.tertiary)
.padding(6)
}
}
}
.frame(height: listHeight)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.primary.opacity(0.04)))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.primary.opacity(0.08)))

// Keep every selection visible even when the filter hides its row.
if !selection.isEmpty {
Text("Joining: " + selection.sorted().joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}

/// Grows with content, capped so a long network list scrolls in place
/// instead of stretching the form.
private var listHeight: CGFloat {
let rows = CGFloat(max(visible.count, 1))
return min(rows * rowHeight + 8, 150)
}

private func row(_ name: String) -> some View {
let isSelected = selection.contains(name)
return Button {
if isSelected {
selection.removeAll { $0 == name }
} else {
selection.append(name)
}
} label: {
HStack(spacing: 8) {
Image(systemName: isSelected ? "checkmark.square.fill" : "square")
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
Text(name)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if let driver = drivers[name] {
Text(driver)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 8)
.frame(height: rowHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(isSelected ? Color.accentColor.opacity(0.08) : .clear)
}
}
Loading
Loading