From 43096089b80a22476dbc4d6fcee2cc6511165945 Mon Sep 17 00:00:00 2001 From: DonsWayo Date: Wed, 17 Jun 2026 13:13:46 +0200 Subject: [PATCH] Networks & volumes: list / create / delete - ContainerNetwork + ContainerVolume models - InfraEngine protocol; SDKInfraEngine lists via the SDK (NetworkClient/ClientVolume) and creates/deletes via the container CLI - AppState: networks/volumes state + loadInfra + create/delete actions - InfraWindow: Networks + Volumes sections (list, delete, create-by-name); opened from the panel footer (footer buttons reflowed to fit: Images/Net icon-only) - Test: infra command arg-building (28 tests pass) - Verified live: lists the real 'default' network (192.168.64.0/24) Closes #3 --- App/AppState.swift | 28 ++++++ App/ConsaiApp.swift | 5 ++ App/Shots/ShotRenderer.swift | 2 + App/Views/InfraWindow.swift | 86 +++++++++++++++++++ App/Views/PanelView.swift | 17 ++-- .../Sources/ConsaiCore/Engines/Engines.swift | 10 +++ .../ConsaiCore/Engines/SDKInfraEngine.swift | 51 +++++++++++ .../Sources/ConsaiCore/Models/Infra.swift | 27 ++++++ .../ConsaiCoreTests/CLIEngineTests.swift | 7 ++ 9 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 App/Views/InfraWindow.swift create mode 100644 ConsaiCore/Sources/ConsaiCore/Engines/SDKInfraEngine.swift create mode 100644 ConsaiCore/Sources/ConsaiCore/Models/Infra.swift diff --git a/App/AppState.swift b/App/AppState.swift index 824fe73..4380123 100644 --- a/App/AppState.swift +++ b/App/AppState.swift @@ -16,12 +16,15 @@ final class AppState { var lastError: String? private(set) var images: [ContainerImage] = [] + private(set) var networks: [ContainerNetwork] = [] + private(set) var volumes: [ContainerVolume] = [] private let containerEngine: ContainerEngine private let composeEngine: ComposeEngine private let serviceHealth: ServiceHealthChecking private let creator: ContainerCreating private let imageEngine: ImageEngine + private let infraEngine: InfraEngine private let store: RegistryStore private var registry: ProjectRegistry private var pollTask: Task? @@ -53,6 +56,7 @@ final class AppState { serviceHealth: ServiceHealthChecking = CLIServiceHealth(binaryPath: AppState.storedPath("containerBinaryPath")), creator: ContainerCreating = CLIContainerCreator(binaryPath: AppState.storedPath("containerBinaryPath")), imageEngine: ImageEngine = SDKImageEngine(binaryPath: AppState.storedPath("containerBinaryPath")), + infraEngine: InfraEngine = SDKInfraEngine(binaryPath: AppState.storedPath("containerBinaryPath")), store: RegistryStore = RegistryStore() ) { self.containerEngine = containerEngine @@ -60,6 +64,7 @@ final class AppState { self.serviceHealth = serviceHealth self.creator = creator self.imageEngine = imageEngine + self.infraEngine = infraEngine self.store = store self.registry = store.load() startPolling() @@ -286,6 +291,29 @@ final class AppState { } } + // MARK: - Networks & volumes + + func loadInfra() async { + do { + async let n = infraEngine.listNetworks() + async let v = infraEngine.listVolumes() + networks = try await n + volumes = try await v + } catch { + lastError = describe(error) + } + } + + func createNetwork(_ name: String) async { await infraOp { try await self.infraEngine.createNetwork(name: name) } } + func deleteNetwork(_ id: String) async { await infraOp { try await self.infraEngine.deleteNetwork(id: id) } } + func createVolume(_ name: String) async { await infraOp { try await self.infraEngine.createVolume(name: name) } } + func deleteVolume(_ name: String) async { await infraOp { try await self.infraEngine.deleteVolume(name: name) } } + + private func infraOp(_ op: @escaping () async throws -> Void) async { + do { try await op(); await loadInfra() } + catch { lastError = describe(error) } + } + // MARK: - Helpers private func act(_ id: String, optimistic: ContainerStatus, _ op: @escaping () async throws -> Void) async { diff --git a/App/ConsaiApp.swift b/App/ConsaiApp.swift index f1547be..c5bf897 100644 --- a/App/ConsaiApp.swift +++ b/App/ConsaiApp.swift @@ -57,6 +57,11 @@ struct ConsaiApp: App { } .defaultSize(width: 560, height: 420) + Window("Networks & Volumes", id: "infra") { + InfraWindow().environment(appState) + } + .defaultSize(width: 560, height: 460) + Window("Consai Settings", id: "settings") { SettingsWindow().environment(appState) .preferredColorScheme(.dark).tint(Theme.jade) diff --git a/App/Shots/ShotRenderer.swift b/App/Shots/ShotRenderer.swift index d5e5d30..d6280f1 100644 --- a/App/Shots/ShotRenderer.swift +++ b/App/Shots/ShotRenderer.swift @@ -56,6 +56,8 @@ enum ShotRenderer { await shoot(PanelView().environment(state), width: 360, height: 600, name: "live-panel", dir: dir) await state.loadImages() await shoot(ImagesWindow().environment(state), width: 560, height: 420, name: "live-images", dir: dir) + await state.loadInfra() + await shoot(InfraWindow().environment(state), width: 560, height: 460, name: "live-infra", dir: dir) FileHandle.standardError.write(Data("rendered live panel to \(dir.path)\n".utf8)) } diff --git a/App/Views/InfraWindow.swift b/App/Views/InfraWindow.swift new file mode 100644 index 0000000..d440162 --- /dev/null +++ b/App/Views/InfraWindow.swift @@ -0,0 +1,86 @@ +import SwiftUI +import ConsaiCore +import AppKit + +/// Networks & volumes: list, create by name, delete. +struct InfraWindow: View { + @Environment(AppState.self) private var appState + @State private var newNetwork = "" + @State private var newVolume = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + sectionHeader("NETWORKS") + createBar(placeholder: "network name", text: $newNetwork) { + let n = newNetwork.trimmingCharacters(in: .whitespaces) + guard !n.isEmpty else { return } + newNetwork = ""; Task { await appState.createNetwork(n) } + } + ForEach(appState.networks) { net in + infraRow(title: net.name, subtitle: net.subnet ?? "") { + Task { await appState.deleteNetwork(net.id) } + } + } + if appState.networks.isEmpty { emptyRow("No networks") } + + sectionHeader("VOLUMES") + createBar(placeholder: "volume name", text: $newVolume) { + let v = newVolume.trimmingCharacters(in: .whitespaces) + guard !v.isEmpty else { return } + newVolume = ""; Task { await appState.createVolume(v) } + } + ForEach(appState.volumes) { vol in + infraRow(title: vol.name, subtitle: "\(vol.driver) · \(vol.source)") { + Task { await appState.deleteVolume(vol.name) } + } + } + if appState.volumes.isEmpty { emptyRow("No volumes") } + } + .padding(.bottom, 8) + } + .frame(minWidth: 520, minHeight: 380) + .background(Theme.bg) + .preferredColorScheme(.dark).tint(Theme.jade) + .navigationTitle("Networks & Volumes") + .onAppear { + NSApp.activate(ignoringOtherApps: true) + Task { await appState.loadInfra() } + } + } + + private func sectionHeader(_ text: String) -> some View { + Text(text).font(Theme.sectionLabel).tracking(2).foregroundStyle(Theme.dim2) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 4) + } + + private func createBar(placeholder: String, text: Binding, add: @escaping () -> Void) -> some View { + HStack(spacing: 8) { + TextField(placeholder, text: text).textFieldStyle(.roundedBorder).onSubmit(add) + Button("Create", action: add) + .disabled(text.wrappedValue.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(.horizontal, 16).padding(.vertical, 6) + } + + private func infraRow(title: String, subtitle: String, delete: @escaping () -> Void) -> some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 1) { + Text(title).font(Theme.ui(13, .medium)).foregroundStyle(Theme.text).lineLimit(1) + if !subtitle.isEmpty { + Text(subtitle).font(Theme.mono(10)).foregroundStyle(Theme.dim2).lineLimit(1).truncationMode(.middle) + } + } + Spacer(minLength: 8) + Button(action: delete) { Image(systemName: "trash") } + .buttonStyle(.plain).foregroundStyle(Theme.dim).help("Delete") + } + .padding(.horizontal, 16).padding(.vertical, 7) + } + + private func emptyRow(_ text: String) -> some View { + Text(text).font(Theme.ui(12)).foregroundStyle(Theme.dim2) + .padding(.horizontal, 16).padding(.vertical, 6) + } +} diff --git a/App/Views/PanelView.swift b/App/Views/PanelView.swift index 6fc78fc..79958ae 100644 --- a/App/Views/PanelView.swift +++ b/App/Views/PanelView.swift @@ -103,28 +103,29 @@ struct PanelView: View { } private var footer: some View { - HStack(spacing: 16) { - footerButton("plus", "Grow") { openWindow(id: "create") } + HStack(spacing: 12) { + footerButton("plus", "Grow", help: "New container") { openWindow(id: "create") } if appState.composeAvailable { - footerButton("square.stack.3d.up", "Stack") { + footerButton("square.stack.3d.up", "Stack", help: "Start a compose stack") { if let file = ComposeFilePicker.pick() { Task { await appState.composeUp(file: file) } } } } - footerButton("photo.stack", "Images") { openWindow(id: "images") } + footerButton("photo.stack", nil, help: "Images") { openWindow(id: "images") } + footerButton("network", nil, help: "Networks & volumes") { openWindow(id: "infra") } Spacer() - footerButton("arrow.clockwise", nil) { Task { await appState.refresh() } } - footerButton("gearshape", "Tend") { openWindow(id: "settings") } + footerButton("arrow.clockwise", nil, help: "Refresh") { Task { await appState.refresh() } } + footerButton("gearshape", "Tend", help: "Settings") { openWindow(id: "settings") } } .padding(.horizontal, 16).padding(.vertical, 11) } - private func footerButton(_ symbol: String, _ title: String?, action: @escaping () -> Void) -> some View { + private func footerButton(_ symbol: String, _ title: String?, help: String, action: @escaping () -> Void) -> some View { Button(action: action) { HStack(spacing: 5) { Image(systemName: symbol).font(.system(size: 12)) if let title { Text(title).font(Theme.ui(12)) } } } - .buttonStyle(.plain).foregroundStyle(Theme.dim) + .buttonStyle(.plain).foregroundStyle(Theme.dim).help(help) } } diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift b/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift index 6e09cac..05c1b7c 100644 --- a/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift +++ b/ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift @@ -32,6 +32,16 @@ public protocol ImageEngine: Sendable { func delete(reference: String) async throws } +/// Manages networks and volumes. `list` uses the SDK; create/delete shell out to the CLI. +public protocol InfraEngine: Sendable { + func listNetworks() async throws -> [ContainerNetwork] + func createNetwork(name: String) async throws + func deleteNetwork(id: String) async throws + func listVolumes() async throws -> [ContainerVolume] + func createVolume(name: String) async throws + func deleteVolume(name: String) async throws +} + /// Checks/controls the `container` system service. /// The concrete `CLIServiceHealth` is implemented in Wave 1. public protocol ServiceHealthChecking: Sendable { diff --git a/ConsaiCore/Sources/ConsaiCore/Engines/SDKInfraEngine.swift b/ConsaiCore/Sources/ConsaiCore/Engines/SDKInfraEngine.swift new file mode 100644 index 0000000..e86d27c --- /dev/null +++ b/ConsaiCore/Sources/ConsaiCore/Engines/SDKInfraEngine.swift @@ -0,0 +1,51 @@ +import Foundation +import ContainerAPIClient + +/// `InfraEngine`: lists networks/volumes via the SDK; create/delete via the `container +/// network|volume …` CLI (avoids building SDK configuration structs by hand). +public struct SDKInfraEngine: InfraEngine { + private let binaryURL: URL? + private let runner: ProcessRunning + + init(binaryURL: URL?, runner: ProcessRunning) { + self.binaryURL = binaryURL + self.runner = runner + } + + public init(binaryPath: String? = nil, runner: ProcessRunning = SystemProcessRunner()) { + self.init(binaryURL: ContainerBinary.resolve(explicit: binaryPath), runner: runner) + } + + public func listNetworks() async throws -> [ContainerNetwork] { + do { + return try await NetworkClient().list() + .map { ContainerNetwork(name: $0.name, subnet: String(describing: $0.status.ipv4Subnet)) } + .sorted { $0.name < $1.name } + } catch { throw ConsaiError.sdk(String(describing: error)) } + } + + public func listVolumes() async throws -> [ContainerVolume] { + do { + return try await ClientVolume.list() + .map { ContainerVolume(name: $0.name, driver: $0.driver, source: $0.source) } + .sorted { $0.name < $1.name } + } catch { throw ConsaiError.sdk(String(describing: error)) } + } + + public func createNetwork(name: String) async throws { try await run(["network", "create", name]) } + public func deleteNetwork(id: String) async throws { try await run(["network", "delete", id]) } + public func createVolume(name: String) async throws { try await run(["volume", "create", name]) } + public func deleteVolume(name: String) async throws { try await run(["volume", "delete", name]) } + + private func run(_ arguments: [String]) async throws { + guard let binaryURL else { throw ConsaiError.sdk("`container` CLI not found") } + let result = try await runner.run(executable: binaryURL.path, arguments: arguments, cwd: nil) + if result.exitCode != 0 { + throw ConsaiError.processFailed(stderr: result.stderr.isEmpty ? result.stdout : result.stderr) + } + } + + // Pure arg-building for tests. + static func networkCreateArgs(_ name: String) -> [String] { ["network", "create", name] } + static func volumeDeleteArgs(_ name: String) -> [String] { ["volume", "delete", name] } +} diff --git a/ConsaiCore/Sources/ConsaiCore/Models/Infra.swift b/ConsaiCore/Sources/ConsaiCore/Models/Infra.swift new file mode 100644 index 0000000..ccf6b56 --- /dev/null +++ b/ConsaiCore/Sources/ConsaiCore/Models/Infra.swift @@ -0,0 +1,27 @@ +import Foundation + +/// A container network. +public struct ContainerNetwork: Identifiable, Hashable, Sendable { + public var id: String { name } + public let name: String + public let subnet: String? + + public init(name: String, subnet: String? = nil) { + self.name = name + self.subnet = subnet + } +} + +/// A named volume. +public struct ContainerVolume: Identifiable, Hashable, Sendable { + public var id: String { name } + public let name: String + public let driver: String + public let source: String + + public init(name: String, driver: String, source: String) { + self.name = name + self.driver = driver + self.source = source + } +} diff --git a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift index 0e0de2d..0a417d9 100644 --- a/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift +++ b/ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift @@ -113,6 +113,13 @@ final class SpyProcessRunner: ProcessRunning, @unchecked Sendable { } } +@Suite struct SDKInfraEngineTests { + @Test func buildsInfraCommandArgs() { + #expect(SDKInfraEngine.networkCreateArgs("backend") == ["network", "create", "backend"]) + #expect(SDKInfraEngine.volumeDeleteArgs("pgdata") == ["volume", "delete", "pgdata"]) + } +} + @Suite struct CLIServiceHealthTests { @Test func parsesNegativeSignalsAsStopped() { #expect(CLIServiceHealth.parseStatus(exitCode: 0, output: "apiserver is not running") == .stopped)