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
28 changes: 28 additions & 0 deletions App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
Expand Down Expand Up @@ -53,13 +56,15 @@ 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
self.composeEngine = composeEngine
self.serviceHealth = serviceHealth
self.creator = creator
self.imageEngine = imageEngine
self.infraEngine = infraEngine
self.store = store
self.registry = store.load()
startPolling()
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions App/ConsaiApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions App/Shots/ShotRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
86 changes: 86 additions & 0 deletions App/Views/InfraWindow.swift
Original file line number Diff line number Diff line change
@@ -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<String>, 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)
}
}
17 changes: 9 additions & 8 deletions App/Views/PanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 10 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/Engines.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Engines/SDKInfraEngine.swift
Original file line number Diff line number Diff line change
@@ -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] }
}
27 changes: 27 additions & 0 deletions ConsaiCore/Sources/ConsaiCore/Models/Infra.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 7 additions & 0 deletions ConsaiCore/Tests/ConsaiCoreTests/CLIEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading