first commit

This commit is contained in:
2026-04-10 22:30:46 +02:00
commit 2e7e931c3b
44 changed files with 3345 additions and 0 deletions
BIN
View File
Binary file not shown.
+337
View File
@@ -0,0 +1,337 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
2617D1562F89084500DEE247 /* dock-g.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "dock-g.app"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
2617D1582F89084500DEE247 /* dock-g */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "dock-g";
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
2617D1532F89084400DEE247 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
2617D14D2F89084400DEE247 = {
isa = PBXGroup;
children = (
2617D1582F89084500DEE247 /* dock-g */,
2617D1572F89084500DEE247 /* Products */,
);
sourceTree = "<group>";
};
2617D1572F89084500DEE247 /* Products */ = {
isa = PBXGroup;
children = (
2617D1562F89084500DEE247 /* dock-g.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
2617D1552F89084400DEE247 /* dock-g */ = {
isa = PBXNativeTarget;
buildConfigurationList = 2617D1612F89084500DEE247 /* Build configuration list for PBXNativeTarget "dock-g" */;
buildPhases = (
2617D1522F89084400DEE247 /* Sources */,
2617D1532F89084400DEE247 /* Frameworks */,
2617D1542F89084400DEE247 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
2617D1582F89084500DEE247 /* dock-g */,
);
name = "dock-g";
packageProductDependencies = (
);
productName = "dock-g";
productReference = 2617D1562F89084500DEE247 /* dock-g.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
2617D14E2F89084400DEE247 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2640;
LastUpgradeCheck = 2640;
TargetAttributes = {
2617D1552F89084400DEE247 = {
CreatedOnToolsVersion = 26.4;
};
};
};
buildConfigurationList = 2617D1512F89084400DEE247 /* Build configuration list for PBXProject "dock-g" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 2617D14D2F89084400DEE247;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 2617D1572F89084500DEE247 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
2617D1552F89084400DEE247 /* dock-g */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
2617D1542F89084400DEE247 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
2617D1522F89084400DEE247 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
2617D15F2F89084500DEE247 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
2617D1602F89084500DEE247 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
2617D1622F89084500DEE247 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "Team.dock-g";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
2617D1632F89084500DEE247 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = EKFHUHT63T;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "Team.dock-g";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
2617D1512F89084400DEE247 /* Build configuration list for PBXProject "dock-g" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2617D15F2F89084500DEE247 /* Debug */,
2617D1602F89084500DEE247 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
2617D1612F89084500DEE247 /* Build configuration list for PBXNativeTarget "dock-g" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2617D1622F89084500DEE247 /* Debug */,
2617D1632F89084500DEE247 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 2617D14E2F89084400DEE247 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>dock-g.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+42
View File
@@ -0,0 +1,42 @@
//
// ContentView.swift
// dock-g
//
// Created by Sven Hanold on 10.04.26.
//
import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var appState
var body: some View {
@Bindable var state = appState
TabView(selection: $state.selectedTab) {
StacksView()
.tabItem {
Label("Stacks", systemImage: "shippingbox.fill")
}
.tag(AppState.Tab.stacks)
ServersView()
.tabItem {
Label("Servers", systemImage: "server.rack")
}
.tag(AppState.Tab.servers)
SettingsView()
.tabItem {
Label("Settings", systemImage: "gearshape.fill")
}
.tag(AppState.Tab.settings)
}
.tint(.appAccent)
.preferredColorScheme(appState.appearanceMode.colorScheme)
}
}
#Preview {
ContentView()
.environment(AppState())
}
+22
View File
@@ -0,0 +1,22 @@
import Foundation
struct DockgeServer: Identifiable, Codable {
var id: UUID
var name: String // Friendly label, e.g. "Home Server"
var host: String // e.g. "myserver.home:5001"
var useSSL: Bool
init(id: UUID = UUID(), name: String, host: String, useSSL: Bool = false) {
self.id = id
self.name = name
self.host = host
self.useSSL = useSSL
}
var baseURL: URL? {
let scheme = useSSL ? "https" : "http"
return URL(string: "\(scheme)://\(host)")
}
var displayHost: String { host }
}
+18
View File
@@ -0,0 +1,18 @@
import SwiftUI
struct ServiceStatus: Identifiable, Codable {
var id: String { name }
var name: String
var state: String
var ports: [String]
var stateColor: Color {
switch state {
case "running": return Color(hex: "#22c55e")
case "exited": return Color(hex: "#ef4444")
case "starting": return Color(hex: "#f59e0b")
case "healthy": return Color(hex: "#22c55e")
default: return Color(hex: "#6b7280")
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import Foundation
struct Stack: Identifiable, Codable, Equatable {
var id: String { "\(endpoint)/\(name)" }
var name: String
var status: StackStatus
var isManagedByDockge: Bool
var composeFileName: String
var endpoint: String
var primaryHostname: String
var composeYAML: String?
var composeENV: String?
var tags: [String]
init(
name: String,
status: StackStatus = .unknown,
isManagedByDockge: Bool = true,
composeFileName: String = "compose.yaml",
endpoint: String = "",
primaryHostname: String = "",
composeYAML: String? = nil,
composeENV: String? = nil,
tags: [String] = []
) {
self.name = name
self.status = status
self.isManagedByDockge = isManagedByDockge
self.composeFileName = composeFileName
self.endpoint = endpoint
self.primaryHostname = primaryHostname
self.composeYAML = composeYAML
self.composeENV = composeENV
self.tags = tags
}
// Merges simple info (from stackList event) into an existing detailed stack
func withSimpleInfo(status: StackStatus, isManagedByDockge: Bool, tags: [String]) -> Stack {
var copy = self
copy.status = status
copy.isManagedByDockge = isManagedByDockge
copy.tags = tags
return copy
}
}
+31
View File
@@ -0,0 +1,31 @@
import SwiftUI
enum StackStatus: Int, Codable {
case unknown = 0
case createdFile = 1
case createdStack = 2
case running = 3
case exited = 4
var label: String {
switch self {
case .unknown: return "Unknown"
case .createdFile: return "Draft"
case .createdStack: return "Inactive"
case .running: return "Running"
case .exited: return "Exited"
}
}
var color: Color {
switch self {
case .unknown: return Color(hex: "#374151")
case .createdFile: return Color(hex: "#6b7280")
case .createdStack: return Color(hex: "#6b7280")
case .running: return Color(hex: "#22c55e")
case .exited: return Color(hex: "#ef4444")
}
}
var isActive: Bool { self == .running }
}
+488
View File
@@ -0,0 +1,488 @@
import Foundation
// All Dockge-specific Socket.IO operations for a single server connection.
@Observable
@MainActor
final class DockgeService {
enum AuthState: Equatable {
case disconnected
case connecting
case needsLogin
case needsSetup
case twoFactorRequired
case authenticated
case error(String)
}
var authState: AuthState = .disconnected
var serverInfo: ServerInfo?
/// Stack list updated directly from the "stackList" socket event.
var stacks: [Stack] = []
struct ServerInfo {
var version: String
var primaryHostname: String
}
private let socket: SocketIOClient
private let server: DockgeServer
// Terminal buffers keyed by terminalName
private(set) var terminalBuffers: [String: String] = [:]
private var terminalListeners: [String: [(String) -> Void]] = [:]
/// Stacks indexed by agent endpoint so partial updates don't wipe other agents.
private var stacksByEndpoint: [String: [Stack]] = [:]
// Reconnect state
private var intentionalDisconnect = false
private var reconnectDelay: TimeInterval = 1
private var reconnectTask: Task<Void, Never>?
/// All known agent endpoints (empty string = local agent).
var availableEndpoints: [String] {
Array(stacksByEndpoint.keys).sorted()
}
init(server: DockgeServer) {
self.server = server
self.socket = SocketIOClient(allowSelfSigned: true)
}
// MARK: - Connection
func connect() {
intentionalDisconnect = false
reconnectDelay = 1
reconnectTask?.cancel()
reconnectTask = nil
setupHandlers()
doConnect()
}
/// Reconnect after an unexpected drop (e.g. from pull-to-refresh or explicit retry).
func reconnect() {
guard !intentionalDisconnect else { return }
reconnectDelay = 1
reconnectTask?.cancel()
reconnectTask = nil
doConnect()
}
func disconnect() {
intentionalDisconnect = true
reconnectTask?.cancel()
reconnectTask = nil
socket.clearHandlers()
socket.disconnect()
authState = .disconnected
stacks = []
stacksByEndpoint = [:]
}
// MARK: - Private connection helpers
private func setupHandlers() {
socket.clearHandlers()
socket.onConnect { [weak self] in
Task { @MainActor [weak self] in
print("[DockgeService] Socket connected")
self?.reconnectDelay = 1 // reset backoff on success
await self?.onSocketConnected()
}
}
socket.onDisconnect { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
print("[DockgeService] Socket disconnected")
self.authState = .disconnected
if !self.intentionalDisconnect {
self.scheduleReconnect()
}
}
}
socket.onError { error in
print("[DockgeService] Socket error: \(error)")
}
socket.on("agent") { [weak self] data in
self?.handleAgentEvent(data)
}
socket.on("stackList") { [weak self] data in
print("[DockgeService] Received legacy stackList event")
self?.handleLegacyStackList(data)
}
socket.on("terminalWrite") { [weak self] data in
self?.handleTerminalWrite(data)
}
socket.on("info") { [weak self] data in
self?.handleServerInfo(data)
}
}
private func doConnect() {
guard let url = server.baseURL else {
authState = .error("Invalid server URL")
return
}
authState = .connecting
print("[DockgeService] Connecting to \(url) (reconnectDelay was \(reconnectDelay)s)")
socket.connect(to: url)
}
private func scheduleReconnect() {
let delay = reconnectDelay
reconnectDelay = min(reconnectDelay * 2, 30)
print("[DockgeService] Scheduling reconnect in \(delay)s")
reconnectTask?.cancel()
reconnectTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
await MainActor.run { [weak self] in
guard let self, !self.intentionalDisconnect else { return }
self.doConnect()
}
}
}
// MARK: - Auth
private func onSocketConnected() async {
if let token = KeychainService.load(forKey: KeychainService.tokenKey(for: server.id)) {
print("[DockgeService] Trying loginByToken")
let result = await socket.emitWithAck("loginByToken", token)
print("[DockgeService] loginByToken result: \(result)")
if let dict = result.first as? [String: Any], dict["ok"] as? Bool == true {
authState = .authenticated
requestStackList()
return
}
KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id))
}
authState = .needsLogin
}
/// Returns nil on success, error message on failure.
func login(username: String, password: String, twoFAToken: String? = nil) async -> String? {
var payload: [String: Any] = ["username": username, "password": password]
if let t = twoFAToken { payload["token"] = t }
let result = await socket.emitWithAck("login", payload)
print("[DockgeService] login result: \(result)")
guard let dict = result.first as? [String: Any] else { return "No response from server" }
if dict["tokenRequired"] as? Bool == true {
authState = .twoFactorRequired
return "2fa"
}
if dict["ok"] as? Bool == true, let token = dict["token"] as? String {
KeychainService.save(token, forKey: KeychainService.tokenKey(for: server.id))
authState = .authenticated
requestStackList()
return nil
}
return localizedError(dict["msg"] as? String ?? "Login failed")
}
func setup(username: String, password: String) async -> String? {
let result = await socket.emitWithAck("setup", username, password)
guard let dict = result.first as? [String: Any] else { return "No response" }
if dict["ok"] as? Bool == true {
return await login(username: username, password: password)
}
return dict["msg"] as? String ?? "Setup failed"
}
func logout() {
KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id))
authState = .needsLogin
}
// MARK: - Stack list
/// Fire-and-forget: Dockge responds via a "stackList" broadcast event, not an ack.
func requestStackList() {
print("[DockgeService] Emitting requestStackList")
socket.emit("requestStackList")
}
// MARK: - Stack operations (return nil on success, error string on failure)
func getStack(name: String, endpoint: String) async -> Stack? {
print("[DockgeService] getStack '\(name)' endpoint='\(endpoint)'")
// All stack ops go through the agent proxy: emit("agent", endpoint, event, ...args)
let result = await socket.emitWithAck("agent", endpoint, "getStack", name)
print("[DockgeService] getStack result: \(result)")
guard let dict = result.first as? [String: Any],
dict["ok"] as? Bool == true,
let stackDict = dict["stack"] as? [String: Any] else { return nil }
return parseDetailedStack(stackDict)
}
@discardableResult
func startStack(_ name: String, endpoint: String) async -> String? {
return await stackAction("startStack", stackName: name, endpoint: endpoint)
}
@discardableResult
func stopStack(_ name: String, endpoint: String) async -> String? {
return await stackAction("stopStack", stackName: name, endpoint: endpoint)
}
@discardableResult
func restartStack(_ name: String, endpoint: String) async -> String? {
return await stackAction("restartStack", stackName: name, endpoint: endpoint)
}
@discardableResult
func updateStack(_ name: String, endpoint: String) async -> String? {
return await stackAction("updateStack", stackName: name, endpoint: endpoint)
}
@discardableResult
func downStack(_ name: String, endpoint: String) async -> String? {
return await stackAction("downStack", stackName: name, endpoint: endpoint)
}
@discardableResult
func deleteStack(_ name: String, endpoint: String) async -> String? {
return await stackAction("deleteStack", stackName: name, endpoint: endpoint)
}
@discardableResult
func deployStack(name: String, yaml: String, env: String, isAdd: Bool, endpoint: String) async -> String? {
let result = await socket.emitWithAck("agent", endpoint, "deployStack", name, yaml, env, isAdd)
return parseOkResult(result)
}
@discardableResult
func saveStack(name: String, yaml: String, env: String, isAdd: Bool, endpoint: String) async -> String? {
let result = await socket.emitWithAck("agent", endpoint, "saveStack", name, yaml, env, isAdd)
return parseOkResult(result)
}
/// Convert a `docker run` command to a compose.yaml snippet.
/// Handled by the main server (not agent proxy).
func composerize(_ dockerRunCommand: String) async -> String? {
let result = await socket.emitWithAck("composerize", dockerRunCommand)
guard let dict = result.first as? [String: Any],
dict["ok"] as? Bool == true,
let content = dict["content"] as? String else { return nil }
return content
}
func serviceStatusList(stackName: String, endpoint: String) async -> [ServiceStatus] {
print("[DockgeService] serviceStatusList '\(stackName)' endpoint='\(endpoint)'")
let result = await socket.emitWithAck("agent", endpoint, "serviceStatusList", stackName)
print("[DockgeService] serviceStatusList result: \(result)")
guard let dict = result.first as? [String: Any],
dict["ok"] as? Bool == true,
let statusDict = dict["serviceStatusList"] as? [String: Any] else { return [] }
return statusDict.map { (svcName, value) -> ServiceStatus in
let info = value as? [String: Any] ?? [:]
return ServiceStatus(
name: svcName,
state: info["state"] as? String ?? "unknown",
ports: info["ports"] as? [String] ?? []
)
}.sorted { $0.name < $1.name }
}
// MARK: - Terminal
func onTerminalWrite(name: String, handler: @escaping (String) -> Void) {
terminalListeners[name, default: []].append(handler)
if let existing = terminalBuffers[name], !existing.isEmpty {
handler(existing)
}
}
func removeTerminalListeners(name: String) {
terminalListeners.removeValue(forKey: name)
}
func clearTerminalBuffer(name: String) {
terminalBuffers.removeValue(forKey: name)
}
func terminalName(for stackName: String, endpoint: String = "") -> String {
"compose-\(endpoint)-\(stackName)"
}
func combinedTerminalName(for stackName: String, endpoint: String = "") -> String {
"combined-\(endpoint)-\(stackName)"
}
/// Fetch the action terminal buffer (compose/start/stop/restart/update/down) via terminalJoin.
func joinActionTerminal(stackName: String, endpoint: String) async {
let termName = terminalName(for: stackName, endpoint: endpoint)
print("[DockgeService] terminalJoin action '\(termName)'")
let result = await socket.emitWithAck("agent", endpoint, "terminalJoin", termName)
let buffer: String?
if let dict = result.first as? [String: Any] {
buffer = dict["buffer"] as? String
} else {
buffer = result.first as? String
}
guard let buf = buffer, !buf.isEmpty else { return }
terminalBuffers[termName] = buf
terminalListeners[termName]?.forEach { $0(buf) }
}
/// Fetch (or re-fetch) combined logs for a stack via terminalJoin.
/// Replaces the local buffer with the server's authoritative full buffer,
/// then calls all registered listeners with the full content.
/// Safe to call repeatedly for polling each call gives a fresh snapshot.
func joinTerminal(stackName: String, endpoint: String) async {
let termName = combinedTerminalName(for: stackName, endpoint: endpoint)
print("[DockgeService] terminalJoin '\(termName)'")
let result = await socket.emitWithAck("agent", endpoint, "terminalJoin", termName)
let buffer: String?
if let dict = result.first as? [String: Any] {
buffer = dict["buffer"] as? String
} else {
buffer = result.first as? String
}
guard let buf = buffer else { return }
// Replace (not append) so repeated polls give the correct full snapshot
terminalBuffers[termName] = buf
terminalListeners[termName]?.forEach { $0(buf) }
}
func leaveTerminal(stackName: String, endpoint: String) {
socket.emit("agent", endpoint, "leaveCombinedTerminal", stackName)
}
// MARK: - Private event handlers
/// Handles Dockge 1.5+ multi-agent "agent" events.
/// Format: args = ["subCommand", {"ok": true, ...}]
private func handleAgentEvent(_ data: [Any]) {
guard let subCommand = data.first as? String else { return }
let rest = Array(data.dropFirst())
print("[DockgeService] agent sub-command: '\(subCommand)'")
switch subCommand {
case "stackList":
guard let payload = rest.first as? [String: Any],
payload["ok"] as? Bool == true,
let stackDict = payload["stackList"] as? [String: Any] else {
print("[DockgeService] agent stackList: bad payload: \(rest.first ?? "nil")")
return
}
var result: [Stack] = []
var endpointKey = ""
for (stackName, stackValue) in stackDict {
guard let info = stackValue as? [String: Any] else { continue }
let statusRaw = info["status"] as? Int ?? 0
let ep = info["endpoint"] as? String ?? ""
endpointKey = ep
let stack = Stack(
name: stackName,
status: StackStatus(rawValue: statusRaw) ?? .unknown,
isManagedByDockge: info["isManagedByDockge"] as? Bool ?? true,
composeFileName: info["composeFileName"] as? String ?? "compose.yaml",
endpoint: ep,
primaryHostname: info["primaryHostname"] as? String ?? "",
tags: info["tags"] as? [String] ?? []
)
result.append(stack)
}
// Update only this agent's stacks, preserve other agents
stacksByEndpoint[endpointKey] = result
rebuildStacks()
#if DEBUG
print("[DockgeService] agent stackList for endpoint '\(endpointKey)', count: \(result.count), total: \(stacks.count)")
#endif
default:
print("[DockgeService] Unhandled agent sub-command: '\(subCommand)', data: \(rest)")
}
}
/// Legacy Dockge format: outer dict keyed by endpoint inner dict keyed by stackName.
private func handleLegacyStackList(_ data: [Any]) {
guard let outerDict = data.first as? [String: Any] else { return }
for (endpoint, endpointValue) in outerDict {
guard let stacksDict = endpointValue as? [String: Any] else { continue }
var result: [Stack] = []
for (stackName, stackValue) in stacksDict {
guard let info = stackValue as? [String: Any] else { continue }
let statusRaw = info["status"] as? Int ?? 0
result.append(Stack(
name: stackName,
status: StackStatus(rawValue: statusRaw) ?? .unknown,
isManagedByDockge: info["isManagedByDockge"] as? Bool ?? true,
composeFileName: info["composeFileName"] as? String ?? "compose.yaml",
endpoint: info["endpoint"] as? String ?? "",
primaryHostname: info["primaryHostname"] as? String ?? "",
tags: info["tags"] as? [String] ?? []
))
}
stacksByEndpoint[endpoint] = result
}
rebuildStacks()
print("[DockgeService] legacy stackList, total: \(stacks.count)")
}
private func rebuildStacks() {
let rebuilt = stacksByEndpoint.values.flatMap { $0 }.sorted { $0.name < $1.name }
guard rebuilt != stacks else { return }
stacks = rebuilt
}
private func handleTerminalWrite(_ data: [Any]) {
guard let dict = data.first as? [String: Any],
let termName = dict["terminalName"] as? String,
let chunk = dict["data"] as? String else { return }
terminalBuffers[termName, default: ""] += chunk
terminalListeners[termName]?.forEach { $0(chunk) }
}
private func handleServerInfo(_ data: [Any]) {
guard let dict = data.first as? [String: Any] else { return }
serverInfo = ServerInfo(
version: dict["version"] as? String ?? "",
primaryHostname: dict["primaryHostname"] as? String ?? ""
)
print("[DockgeService] Server info: v\(serverInfo?.version ?? "")")
}
// MARK: - Helpers
private func stackAction(_ event: String, stackName: String, endpoint: String) async -> String? {
let result = await socket.emitWithAck("agent", endpoint, event, stackName)
return parseOkResult(result)
}
private func parseOkResult(_ result: [Any]) -> String? {
guard let dict = result.first as? [String: Any] else { return "No response from server" }
if dict["ok"] as? Bool == true { return nil }
return localizedError(dict["msg"] as? String ?? "Unknown error")
}
private func parseDetailedStack(_ dict: [String: Any]) -> Stack {
let statusRaw = dict["status"] as? Int ?? 0
return Stack(
name: dict["name"] as? String ?? "",
status: StackStatus(rawValue: statusRaw) ?? .unknown,
isManagedByDockge: dict["isManagedByDockge"] as? Bool ?? true,
composeFileName: dict["composeFileName"] as? String ?? "compose.yaml",
endpoint: dict["endpoint"] as? String ?? "",
primaryHostname: dict["primaryHostname"] as? String ?? "",
composeYAML: dict["composeYAML"] as? String,
composeENV: dict["composeENV"] as? String,
tags: dict["tags"] as? [String] ?? []
)
}
private func localizedError(_ key: String) -> String {
switch key {
case "authIncorrectCreds": return "Incorrect username or password."
case "authInvalidToken": return "Session expired. Please log in again."
case "authUserInactiveOrDeleted": return "Account is inactive or deleted."
case "Stack not found": return "Stack not found."
case "Stack name already exists": return "A stack with that name already exists."
default: return key
}
}
}
@@ -0,0 +1,40 @@
import Foundation
import Security
enum KeychainService {
static func save(_ value: String, forKey key: String) {
let data = Data(value.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func load(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func delete(forKey key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
static func tokenKey(for serverID: UUID) -> String {
"dockge.token.\(serverID.uuidString)"
}
}
+247
View File
@@ -0,0 +1,247 @@
import Foundation
// Minimal Socket.IO v4 / Engine.IO v4 client over WebSocket.
// No external dependencies uses URLSessionWebSocketTask.
@MainActor
final class SocketIOClient: NSObject {
enum ConnectionState { case disconnected, connecting, connected }
private(set) var connectionState: ConnectionState = .disconnected
private var webSocketTask: URLSessionWebSocketTask?
private var urlSession: URLSession!
private var nextAckId = 0
private var pendingAcks: [Int: ([Any]) -> Void] = [:]
private var eventHandlers: [String: [([Any]) -> Void]] = [:]
private var connectHandlers: [() -> Void] = []
private var disconnectHandlers: [() -> Void] = []
private var errorHandlers: [(Error) -> Void] = []
private var allowSelfSigned: Bool
init(allowSelfSigned: Bool = false) {
self.allowSelfSigned = allowSelfSigned
super.init()
let delegate = allowSelfSigned ? InsecureDelegate() : nil
urlSession = URLSession(
configuration: .default,
delegate: delegate,
delegateQueue: nil
)
}
// MARK: - Public API
func connect(to baseURL: URL) {
guard connectionState == .disconnected else { return }
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
components.scheme = (baseURL.scheme == "https" || baseURL.scheme == "wss") ? "wss" : "ws"
components.path = "/socket.io/"
components.queryItems = [
URLQueryItem(name: "EIO", value: "4"),
URLQueryItem(name: "transport", value: "websocket")
]
guard let wsURL = components.url else { return }
connectionState = .connecting
webSocketTask = urlSession.webSocketTask(with: wsURL)
webSocketTask?.resume()
scheduleReceive()
}
func disconnect() {
connectionState = .disconnected
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
pendingAcks.removeAll()
disconnectHandlers.forEach { $0() }
}
func on(_ event: String, handler: @escaping ([Any]) -> Void) {
eventHandlers[event, default: []].append(handler)
}
func onConnect(handler: @escaping () -> Void) {
connectHandlers.append(handler)
}
func onDisconnect(handler: @escaping () -> Void) {
disconnectHandlers.append(handler)
}
func onError(handler: @escaping (Error) -> Void) {
errorHandlers.append(handler)
}
func clearHandlers() {
eventHandlers.removeAll()
connectHandlers.removeAll()
disconnectHandlers.removeAll()
errorHandlers.removeAll()
}
/// Fire-and-forget emit.
func emit(_ event: String, _ args: Any...) {
sendEvent(event, args: args, ackId: nil)
}
/// Emit with acknowledgement callback.
func emitWithAck(_ event: String, _ args: Any..., timeout: TimeInterval = 30) async -> [Any] {
await withCheckedContinuation { continuation in
let ackId = nextAckId
nextAckId += 1
var settled = false
pendingAcks[ackId] = { data in
guard !settled else { return }
settled = true
continuation.resume(returning: data)
}
sendEvent(event, args: args, ackId: ackId)
Task { [weak self] in
try? await Task.sleep(for: .seconds(timeout))
await MainActor.run {
guard let self, !settled else { return }
settled = true
self.pendingAcks.removeValue(forKey: ackId)
continuation.resume(returning: [])
}
}
}
}
// MARK: - Private send
private func sendEvent(_ event: String, args: [Any], ackId: Int?) {
var payload: [Any] = [event]
payload.append(contentsOf: args)
guard let jsonData = try? JSONSerialization.data(withJSONObject: payload),
let jsonStr = String(data: jsonData, encoding: .utf8) else { return }
let ackStr = ackId.map { String($0) } ?? ""
sendRaw("42\(ackStr)\(jsonStr)")
}
private func sendRaw(_ text: String) {
#if DEBUG
print("[SocketIO] TX: \(text.prefix(200))")
#endif
webSocketTask?.send(.string(text)) { error in
if let error {
print("[SocketIO] send error: \(error.localizedDescription)")
}
}
}
// MARK: - Receive loop
private func scheduleReceive() {
webSocketTask?.receive { [weak self] result in
guard let self else { return }
switch result {
case .success(let message):
let text: String
switch message {
case .string(let s): text = s
case .data(let d): text = String(data: d, encoding: .utf8) ?? ""
@unknown default: text = ""
}
Task { @MainActor [weak self] in
self?.handleRaw(text)
self?.scheduleReceive()
}
case .failure(let error):
Task { @MainActor [weak self] in
guard let self else { return }
self.connectionState = .disconnected
self.errorHandlers.forEach { $0(error) }
self.disconnectHandlers.forEach { $0() }
}
}
}
}
// MARK: - Protocol handling
private func handleRaw(_ text: String) {
guard let first = text.first else { return }
let body = String(text.dropFirst())
#if DEBUG
print("[SocketIO] RX: \(text.prefix(200))")
#endif
switch first {
case "0": // EIO OPEN
sendRaw("40") // SIO CONNECT to default namespace
case "2": // EIO PING respond PONG
sendRaw("3")
case "4": // EIO MESSAGE Socket.IO packet
handleSIOPacket(body)
case "1": // EIO CLOSE
disconnect()
default:
break
}
}
private func handleSIOPacket(_ text: String) {
guard let first = text.first else { return }
let body = String(text.dropFirst())
switch first {
case "0": // SIO CONNECT
connectionState = .connected
connectHandlers.forEach { $0() }
case "2": // SIO EVENT
dispatchEvent(body)
case "3": // SIO ACK
dispatchAck(body)
case "4": // SIO CONNECT_ERROR
print("[SocketIO] connect error: \(body)")
default:
break
}
}
private func dispatchEvent(_ text: String) {
// Optional ack ID before the '[' bracket
let (jsonStr, _) = splitAckPrefix(text)
guard let data = jsonStr.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
let name = array.first as? String else { return }
let args = Array(array.dropFirst())
eventHandlers[name]?.forEach { $0(args) }
}
private func dispatchAck(_ text: String) {
let (jsonStr, ackId) = splitAckPrefix(text)
guard let id = ackId,
let data = jsonStr.data(using: .utf8),
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
let handler = pendingAcks[id] else { return }
pendingAcks.removeValue(forKey: id)
handler(array)
}
/// Splits "N[]" into ("[]", N), or text that starts with "[" into (text, nil).
private func splitAckPrefix(_ text: String) -> (String, Int?) {
guard let bracketIdx = text.firstIndex(of: "[") else { return (text, nil) }
let prefix = String(text[text.startIndex..<bracketIdx])
let json = String(text[bracketIdx...])
return (json, Int(prefix))
}
}
// MARK: - Self-signed certificate delegate
private final class InsecureDelegate: NSObject, URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.performDefaultHandling, nil)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
import Foundation
import SwiftUI
/// Parses ANSI/VT100 escape sequences and returns a styled AttributedString.
/// SGR color/bold/dim codes are applied as SwiftUI attributes.
/// All other escape sequences (cursor movement, erase, etc.) are silently stripped.
enum ANSIParser {
// Matches any ANSI/VT100 escape sequence
private static let escapeRegex = try! NSRegularExpression(
pattern: #"\x1B(?:[@-Z\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)"#
)
// Checks if a matched sequence is an SGR sequence (ends with 'm')
private static let sgrRegex = try! NSRegularExpression(
pattern: #"^\x1B\[([0-9;]*)m$"#
)
// Current SGR render state
private struct Style {
var foreground: Color? = nil
var bold: Bool = false
var dim: Bool = false
mutating func reset() { self = Style() }
mutating func apply(_ code: Int) {
switch code {
case 0: reset()
case 1: bold = true
case 2: dim = true
case 22: bold = false; dim = false
case 39: foreground = nil
// Standard foreground colors (dark-theme optimized)
case 30: foreground = Color(white: 0.35)
case 31: foreground = Color(hex: "#f87171") // red
case 32: foreground = Color(hex: "#4ade80") // green
case 33: foreground = Color(hex: "#facc15") // yellow
case 34: foreground = Color(hex: "#60a5fa") // blue
case 35: foreground = Color(hex: "#e879f9") // magenta
case 36: foreground = Color(hex: "#22d3ee") // cyan
case 37: foreground = Color(white: 0.88) // white
// Bright foreground colors
case 90: foreground = Color(white: 0.50)
case 91: foreground = Color(hex: "#fca5a5")
case 92: foreground = Color(hex: "#86efac")
case 93: foreground = Color(hex: "#fde68a")
case 94: foreground = Color(hex: "#93c5fd")
case 95: foreground = Color(hex: "#f5d0fe")
case 96: foreground = Color(hex: "#a5f3fc")
case 97: foreground = Color(white: 0.97)
default: break // background colors and other codes ignored
}
}
}
// MARK: - Public API
static func attributedString(from input: String) -> AttributedString {
// Normalise line endings: \r\n \n, stray \r \n
let text = input
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
var result = AttributedString()
var style = Style()
let nsText = text as NSString
let fullRange = NSRange(location: 0, length: nsText.length)
let matches = escapeRegex.matches(in: text, range: fullRange)
var lastIdx = text.startIndex
for match in matches {
guard let matchRange = Range(match.range, in: text) else { continue }
// Append plain text before this escape sequence
if lastIdx < matchRange.lowerBound {
result += segment(String(text[lastIdx..<matchRange.lowerBound]), style: style)
}
// Is it an SGR sequence? Apply its codes to style.
let seq = String(text[matchRange])
let seqNS = seq as NSString
let seqRange = NSRange(location: 0, length: seqNS.length)
if let sgr = sgrRegex.firstMatch(in: seq, range: seqRange),
let codeRange = Range(sgr.range(at: 1), in: seq) {
let codeStr = String(seq[codeRange])
if codeStr.isEmpty {
style.apply(0)
} else {
codeStr.split(separator: ";").compactMap { Int($0) }.forEach { style.apply($0) }
}
}
// Non-SGR sequences are dropped (cursor movement, clear screen, etc.)
lastIdx = matchRange.upperBound
}
// Remaining text after the last escape sequence
if lastIdx < text.endIndex {
result += segment(String(text[lastIdx...]), style: style)
}
return result
}
// MARK: - Helpers
private static func segment(_ text: String, style: Style) -> AttributedString {
var s = AttributedString(text)
s.font = .system(
size: 12,
weight: style.bold ? .semibold : .regular,
design: .monospaced
)
let base: Color = style.foreground ?? Color(hex: "#cbd5e1") // default: slate-300
s.foregroundColor = style.dim ? base.opacity(0.5) : base
return s
}
}
@@ -0,0 +1,12 @@
import Foundation
enum ANSIStripper {
// Strips common ANSI/VT100 escape codes from terminal output.
private static let pattern = #"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)"#
private static let regex = try! NSRegularExpression(pattern: pattern)
static func strip(_ input: String) -> String {
let range = NSRange(input.startIndex..., in: input)
return regex.stringByReplacingMatches(in: input, range: range, withTemplate: "")
}
}
+80
View File
@@ -0,0 +1,80 @@
import SwiftUI
import UIKit
// MARK: - Adaptive UIColor palette
extension UIColor {
static let appBackground = UIColor {
$0.userInterfaceStyle == .dark ? UIColor(hex: "#0f0f0f") : UIColor(hex: "#f2f2f7")
}
static let appSurface = UIColor {
$0.userInterfaceStyle == .dark ? UIColor(hex: "#1e1e1e") : UIColor(hex: "#ffffff")
}
static let appSurface2 = UIColor {
$0.userInterfaceStyle == .dark ? UIColor(hex: "#252525") : UIColor(hex: "#f0f0f5")
}
static let appDarkGray = UIColor {
$0.userInterfaceStyle == .dark ? UIColor(hex: "#374151") : UIColor(hex: "#d1d5db")
}
static let appGray = UIColor {
$0.userInterfaceStyle == .dark ? UIColor(hex: "#6b7280") : UIColor(hex: "#4b5563")
}
convenience init(hex: String) {
let h = hex.trimmingCharacters(in: .init(charactersIn: "#"))
let v = UInt64(h, radix: 16) ?? 0
self.init(
red: CGFloat((v >> 16) & 0xff) / 255,
green: CGFloat((v >> 8) & 0xff) / 255,
blue: CGFloat( v & 0xff) / 255,
alpha: 1
)
}
}
// MARK: - Color palette (concrete Color values for explicit use)
extension Color {
static let appBackground = Color(uiColor: .appBackground)
static let appSurface = Color(uiColor: .appSurface)
static let appSurface2 = Color(uiColor: .appSurface2)
static let appAccent = Color(hex: "#3b82f6")
static let appGreen = Color(hex: "#22c55e")
static let appRed = Color(hex: "#ef4444")
static let appGray = Color(uiColor: .appGray)
static let appDarkGray = Color(uiColor: .appDarkGray)
static let terminalBg = Color(hex: "#0d0d0d")
static let terminalText = Color(hex: "#d4d4d4")
/// Initialise from a CSS hex string like "#3b82f6".
init(hex: String) {
let h = hex.trimmingCharacters(in: .init(charactersIn: "#"))
let value = UInt64(h, radix: 16) ?? 0
let r = Double((value >> 16) & 0xff) / 255
let g = Double((value >> 8) & 0xff) / 255
let b = Double( value & 0xff) / 255
self.init(red: r, green: g, blue: b)
}
}
// MARK: - ShapeStyle extensions so dot-syntax works in foregroundStyle()
extension ShapeStyle where Self == Color {
static var appBackground: Color { .appBackground }
static var appSurface: Color { .appSurface }
static var appAccent: Color { .appAccent }
static var appGreen: Color { .appGreen }
static var appRed: Color { .appRed }
static var appGray: Color { .appGray }
static var appDarkGray: Color { .appDarkGray }
static var terminalBg: Color { .terminalBg }
static var terminalText: Color { .terminalText }
}
// MARK: - Font helpers
extension Font {
static let monoSmall = Font.system(size: 12, design: .monospaced)
static let monoBody = Font.system(size: 14, design: .monospaced)
static let monoBold = Font.system(size: 14, weight: .bold, design: .monospaced)
}
+158
View File
@@ -0,0 +1,158 @@
import SwiftUI
enum AppearanceMode: String, CaseIterable {
case system, light, dark
var label: String {
switch self {
case .system: return "System"
case .light: return "Light"
case .dark: return "Dark"
}
}
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
// Central application state inject via .environment(appState)
@Observable
final class AppState {
// MARK: - Servers
var servers: [DockgeServer] = [] {
didSet { persistServers() }
}
// MARK: - Active connection
var activeServer: DockgeServer?
var dockgeService: DockgeService?
/// Stacks are owned by DockgeService; this computed property bridges to SwiftUI observation.
var stacks: [Stack] { dockgeService?.stacks ?? [] }
// MARK: - Navigation
var selectedTab: Tab = .stacks
enum Tab { case stacks, servers, settings }
// MARK: - Init
init() {
loadServers()
loadAppearance()
loadLogRefreshInterval()
}
// MARK: - Server management
func addServer(_ server: DockgeServer) {
servers.append(server)
}
func removeServer(at offsets: IndexSet) {
for index in offsets {
let server = servers[index]
KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id))
if activeServer?.id == server.id { disconnectFromServer() }
}
servers.remove(atOffsets: offsets)
}
func updateServer(_ server: DockgeServer) {
if let index = servers.firstIndex(where: { $0.id == server.id }) {
servers[index] = server
}
}
// MARK: - Connection
func connectToServer(_ server: DockgeServer) {
if dockgeService != nil { disconnectFromServer() }
activeServer = server
let service = DockgeService(server: server)
dockgeService = service
service.connect()
}
func disconnectFromServer() {
dockgeService?.disconnect()
dockgeService = nil
activeServer = nil
}
// MARK: - Persistence (servers stored in UserDefaults as JSON; tokens in Keychain)
// MARK: - Log refresh interval
private let logRefreshKey = "dockge.logRefreshInterval"
var logRefreshInterval: Int = 10 {
didSet { UserDefaults.standard.set(logRefreshInterval, forKey: logRefreshKey) }
}
private func loadLogRefreshInterval() {
let stored = UserDefaults.standard.integer(forKey: logRefreshKey)
if [10, 30, 60].contains(stored) { logRefreshInterval = stored }
}
// MARK: - Appearance
private let appearanceKey = "dockge.appearanceMode"
var appearanceMode: AppearanceMode = .dark {
didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: appearanceKey) }
}
private func loadAppearance() {
guard let raw = UserDefaults.standard.string(forKey: appearanceKey),
let mode = AppearanceMode(rawValue: raw) else { return }
appearanceMode = mode
}
// MARK: - Auto-connect
private let autoConnectKey = "dockge.autoConnectServerID"
var autoConnectServerID: UUID? {
didSet {
if let id = autoConnectServerID {
UserDefaults.standard.set(id.uuidString, forKey: autoConnectKey)
} else {
UserDefaults.standard.removeObject(forKey: autoConnectKey)
}
}
}
func setAutoConnect(for server: DockgeServer?) {
autoConnectServerID = server?.id
}
// MARK: - Persistence (servers stored in UserDefaults as JSON; tokens in Keychain)
private let serversKey = "dockge.servers"
private func persistServers() {
guard let data = try? JSONEncoder().encode(servers) else { return }
UserDefaults.standard.set(data, forKey: serversKey)
}
private func loadServers() {
if let str = UserDefaults.standard.string(forKey: autoConnectKey),
let id = UUID(uuidString: str) {
autoConnectServerID = id
}
guard let data = UserDefaults.standard.data(forKey: serversKey),
let decoded = try? JSONDecoder().decode([DockgeServer].self, from: data) else { return }
servers = decoded
if let id = autoConnectServerID, let server = servers.first(where: { $0.id == id }) {
connectToServer(server)
}
}
}
@@ -0,0 +1,81 @@
import SwiftUI
struct AddServerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(AppState.self) private var appState
@State private var name = ""
@State private var host = ""
@State private var useSSL = false
@State private var editingServer: DockgeServer?
// Pass an existing server to edit
init(server: DockgeServer? = nil) {
if let server {
_name = State(initialValue: server.name)
_host = State(initialValue: server.host)
_useSSL = State(initialValue: server.useSSL)
_editingServer = State(initialValue: server)
}
}
private var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty &&
!host.trimmingCharacters(in: .whitespaces).isEmpty
}
var body: some View {
NavigationStack {
Form {
Section("Server") {
TextField("Friendly name (e.g. Home Server)", text: $name)
.autocorrectionDisabled()
TextField("Host (e.g. 192.168.1.10:5001)", text: $host)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(.URL)
}
.listRowBackground(Color.appSurface)
Section {
Toggle("Use HTTPS / WSS", isOn: $useSSL)
} footer: {
Text("Enable for TLS-secured Dockge instances. Self-signed certificates are accepted.")
}
.listRowBackground(Color.appSurface)
}
.scrollContentBackground(.hidden)
.background(Color.appBackground)
.navigationTitle(editingServer == nil ? "Add Server" : "Edit Server")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(!isValid)
}
}
}
}
private func save() {
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
if var existing = editingServer {
existing.name = name.trimmingCharacters(in: .whitespaces)
existing.host = trimmedHost
existing.useSSL = useSSL
appState.updateServer(existing)
} else {
let server = DockgeServer(
name: name.trimmingCharacters(in: .whitespaces),
host: trimmedHost,
useSSL: useSSL
)
appState.addServer(server)
}
dismiss()
}
}
+150
View File
@@ -0,0 +1,150 @@
import SwiftUI
struct LoginView: View {
let service: DockgeService
let server: DockgeServer
@Environment(\.dismiss) private var dismiss
@State private var username = ""
@State private var password = ""
@State private var twoFAToken = ""
@State private var showTwoFA = false
@State private var showSetup = false
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Logo / header
VStack(spacing: 8) {
Image(systemName: "server.rack")
.font(.system(size: 48))
.foregroundStyle(.appAccent)
Text(server.name)
.font(.title2.bold())
Text(server.displayHost)
.font(.subheadline)
.foregroundStyle(.appGray)
}
.padding(.top, 32)
if let error = errorMessage {
Text(error)
.font(.system(size: 14))
.foregroundStyle(.primary)
.padding(12)
.frame(maxWidth: .infinity)
.background(Color.appRed.opacity(0.8), in: RoundedRectangle(cornerRadius: 8))
}
VStack(spacing: 12) {
if showSetup {
Text("First-time Setup")
.font(.headline)
.foregroundStyle(.appGray)
}
TextField("Username", text: $username)
.textFieldStyle(.plain)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.padding()
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10))
SecureField("Password", text: $password)
.textFieldStyle(.plain)
.padding()
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10))
if showTwoFA {
TextField("2FA Code", text: $twoFAToken)
.textFieldStyle(.plain)
.keyboardType(.numberPad)
.padding()
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10))
}
}
Button {
Task { await submit() }
} label: {
Group {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text(showSetup ? "Create Account" : "Login")
.font(.system(size: 16, weight: .semibold))
}
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.appAccent, in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(.primary)
}
.disabled(isLoading || username.isEmpty || password.isEmpty)
if showSetup {
Button("Back to Login") {
showSetup = false
showTwoFA = false
errorMessage = nil
}
.foregroundStyle(.appAccent)
} else {
Button("First-time Setup") {
showSetup = true
showTwoFA = false
errorMessage = nil
}
.foregroundStyle(.appGray)
.font(.system(size: 13))
}
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
.background(Color.appBackground)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.onChange(of: service.authState) { _, newState in
if case .authenticated = newState { dismiss() }
if case .needsSetup = newState { showSetup = true }
}
}
private func submit() async {
isLoading = true
errorMessage = nil
let error: String?
if showSetup {
error = await service.setup(username: username, password: password)
} else {
error = await service.login(
username: username,
password: password,
twoFAToken: showTwoFA && !twoFAToken.isEmpty ? twoFAToken : nil
)
}
isLoading = false
if let msg = error {
if msg == "2fa" {
showTwoFA = true
errorMessage = "Enter your two-factor authentication code."
} else {
errorMessage = msg
}
}
// On success, onChange(of: service.authState) will dismiss
}
}
@@ -0,0 +1,166 @@
import SwiftUI
struct ServersView: View {
@Environment(AppState.self) private var appState
@State private var showAddServer = false
@State private var serverToEdit: DockgeServer?
var body: some View {
NavigationStack {
Group {
if appState.servers.isEmpty {
ContentUnavailableView {
Label("No Servers", systemImage: "server.rack")
} description: {
Text("Tap + to add a Dockge server.")
} actions: {
Button("Add Server") { showAddServer = true }
.buttonStyle(.borderedProminent)
.tint(.appAccent)
}
} else {
List {
ForEach(appState.servers) { server in
serverRow(server)
.listRowBackground(Color.appSurface)
}
.onDelete(perform: appState.removeServer)
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
}
.background(Color.appBackground)
.navigationTitle("Servers")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button { showAddServer = true } label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddServer) {
AddServerView()
}
.sheet(item: $serverToEdit) { server in
AddServerView(server: server)
}
}
}
@ViewBuilder
private func serverRow(_ server: DockgeServer) -> some View {
HStack(spacing: 12) {
// Connection indicator
Circle()
.fill(appState.activeServer?.id == server.id ? Color.appGreen : Color.appGray)
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(server.name)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
if appState.autoConnectServerID == server.id {
Text("Auto")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color.appAccent)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appAccent.opacity(0.2), in: Capsule())
.overlay(Capsule().strokeBorder(Color.appAccent.opacity(0.4), lineWidth: 0.5))
}
}
HStack(spacing: 4) {
Text(server.displayHost)
.font(.system(size: 12))
.foregroundStyle(.appGray)
if server.useSSL {
Image(systemName: "lock.fill")
.font(.system(size: 10))
.foregroundStyle(.appAccent)
}
}
if let active = appState.activeServer, active.id == server.id,
let info = appState.dockgeService?.serverInfo {
Text("v\(info.version)")
.font(.system(size: 11))
.foregroundStyle(.appGray)
}
}
Spacer()
// Connect / Disconnect button
if appState.activeServer?.id == server.id {
Button {
appState.disconnectFromServer()
} label: {
Text("Disconnect")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.appRed)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.appRed.opacity(0.15), in: Capsule())
}
.buttonStyle(.plain)
} else {
Button {
appState.connectToServer(server)
} label: {
Text("Connect")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.appAccent)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(Color.appAccent.opacity(0.15), in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
if let index = appState.servers.firstIndex(where: { $0.id == server.id }) {
appState.removeServer(at: IndexSet([index]))
}
} label: {
Label("Delete", systemImage: "trash")
}
Button {
serverToEdit = server
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.appAccent)
if appState.activeServer?.id == server.id {
Button {
appState.dockgeService?.logout()
} label: {
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
}
.tint(.appRed)
}
}
.swipeActions(edge: .leading) {
if appState.autoConnectServerID == server.id {
Button {
appState.setAutoConnect(for: nil)
} label: {
Label("Remove Auto", systemImage: "bolt.slash")
}
.tint(.appGray)
} else {
Button {
appState.setAutoConnect(for: server)
} label: {
Label("Auto Connect", systemImage: "bolt")
}
.tint(.appAccent)
}
}
}
}
@@ -0,0 +1,46 @@
import SwiftUI
struct SettingsView: View {
@Environment(AppState.self) private var appState
var body: some View {
NavigationStack {
Form {
Section("Appearance") {
@Bindable var state = appState
Picker("Theme", selection: $state.appearanceMode) {
ForEach(AppearanceMode.allCases, id: \.self) { mode in
Text(mode.label).tag(mode)
}
}
.pickerStyle(.segmented)
}
.listRowBackground(Color.appSurface)
Section("Logs") {
@Bindable var state = appState
Picker("Refresh Rate", selection: $state.logRefreshInterval) {
Text("10 s").tag(10)
Text("30 s").tag(30)
Text("60 s").tag(60)
}
.pickerStyle(.segmented)
}
.listRowBackground(Color.appSurface)
Section("About") {
LabeledContent("App", value: "dock-g")
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
LabeledContent("Version", value: version)
}
LabeledContent("Platform", value: "Dockge Mobile Client")
}
.listRowBackground(Color.appSurface)
}
.scrollContentBackground(.hidden)
.background(Color.appBackground)
.navigationTitle("Settings")
}
}
}
@@ -0,0 +1,75 @@
import SwiftUI
// Shows terminal output after a stack action (start/stop/restart/update/down/deploy etc.)
struct ActionTerminalSheet: View {
let title: String
let terminalName: String
let stackName: String
let endpoint: String
let service: DockgeService
@Environment(\.dismiss) private var dismiss
@State private var displayAttr = AttributedString()
@State private var isLoading = true
var body: some View {
NavigationStack {
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
if isLoading {
ProgressView()
.tint(.appAccent)
.frame(maxWidth: .infinity, alignment: .center)
.padding(24)
} else if displayAttr == AttributedString() {
Text("No output.")
.font(.monoSmall)
.foregroundStyle(Color.appGray)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
} else {
Text(displayAttr)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
Color.clear.frame(height: 1).id("end")
}
.background(Color.terminalBg)
.onChange(of: displayAttr) { _, _ in
withAnimation(.none) { proxy.scrollTo("end") }
}
}
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.tint(.appAccent)
.padding()
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
.onAppear {
// Replay local buffer immediately (synchronous if buffer exists)
service.onTerminalWrite(name: terminalName) { buf in
displayAttr = ANSIParser.attributedString(from: buf)
}
// Show whatever we have right away don't block on a slow/no-op server call
isLoading = false
// Background fallback: ask the server for its buffer in case local events
// were missed (fire-and-forget, updates displayAttr via the listener above)
Task {
await service.joinActionTerminal(stackName: stackName, endpoint: endpoint)
}
}
.onDisappear {
service.removeTerminalListeners(name: terminalName)
}
}
}
@@ -0,0 +1,28 @@
import SwiftUI
struct ComposeTabView: View {
@Binding var yaml: String
var isEditing: Bool
var onSave: () async -> Void
var body: some View {
ScrollView {
if isEditing {
TextEditor(text: $yaml)
.font(.monoBody)
.foregroundStyle(Color.terminalText)
.scrollContentBackground(.hidden)
.frame(minHeight: 400)
.padding(8)
} else {
Text(yaml.isEmpty ? " " : yaml)
.font(.monoBody)
.foregroundStyle(Color.terminalText)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
}
.background(Color.terminalBg)
}
}
@@ -0,0 +1,289 @@
import SwiftUI
struct CreateStackView: View {
let service: DockgeService
@Environment(\.dismiss) private var dismiss
@State private var stackName = ""
@State private var selectedEndpoint = ""
@State private var composeYAML = Self.defaultYAML
@State private var envContent = ""
// Converter
@State private var dockerRunCommand = ""
@State private var isConverting = false
@State private var converterExpanded = false
// Actions
@State private var isWorking = false
@State private var errorMessage: String?
@State private var showTerminal = false
@State private var deployTerminalName = ""
private static let defaultYAML = """
services:
app:
image:
restart: unless-stopped
"""
private var nameIsValid: Bool {
let trimmed = stackName.trimmingCharacters(in: .whitespaces)
return !trimmed.isEmpty && trimmed.range(of: #"^[a-z0-9_-]+$"#, options: .regularExpression) != nil
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
stackInfoSection
converterSection
composeSection
envSection
if let error = errorMessage {
Text(error)
.font(.system(size: 13))
.foregroundStyle(.primary)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.appRed.opacity(0.8), in: RoundedRectangle(cornerRadius: 8))
}
}
.padding()
}
.background(Color.appBackground)
.navigationTitle("New Stack")
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarItems }
}
.sheet(isPresented: $showTerminal) {
ActionTerminalSheet(
title: "Deploying \(stackName)",
terminalName: deployTerminalName,
stackName: stackName,
endpoint: selectedEndpoint,
service: service
)
}
}
// MARK: - Sections
private var stackInfoSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("Stack")
VStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
TextField("stack-name", text: $stackName)
.font(.system(size: 15, design: .monospaced))
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.padding(10)
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 8))
if !stackName.isEmpty && !nameIsValid {
Text("Only lowercase letters, numbers, - and _ allowed")
.font(.system(size: 11))
.foregroundStyle(Color.appRed)
.padding(.leading, 4)
}
}
if service.availableEndpoints.count > 1 {
Picker("Agent", selection: $selectedEndpoint) {
ForEach(service.availableEndpoints, id: \.self) { ep in
Text(ep.isEmpty ? "Local" : ep).tag(ep)
}
}
.pickerStyle(.menu)
.padding(10)
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 8))
}
}
}
}
private var converterSection: some View {
VStack(alignment: .leading, spacing: 12) {
Button {
withAnimation(.easeInOut(duration: 0.2)) { converterExpanded.toggle() }
} label: {
HStack {
sectionHeader("Convert from docker run")
Spacer()
Image(systemName: converterExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color.appGray)
}
}
.buttonStyle(.plain)
if converterExpanded {
VStack(spacing: 8) {
TextEditor(text: $dockerRunCommand)
.font(.monoSmall)
.foregroundStyle(Color.terminalText)
.scrollContentBackground(.hidden)
.frame(minHeight: 80)
.padding(8)
.background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8))
.overlay(
Group {
if dockerRunCommand.isEmpty {
Text("docker run -p 8080:80 nginx")
.font(.monoSmall)
.foregroundStyle(Color.appGray.opacity(0.6))
.allowsHitTesting(false)
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
)
Button {
Task { await runConverter() }
} label: {
HStack(spacing: 6) {
if isConverting {
ProgressView().scaleEffect(0.7)
} else {
Image(systemName: "arrow.triangle.2.circlepath")
}
Text(isConverting ? "Converting…" : "Convert")
}
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.primary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color.appAccent, in: RoundedRectangle(cornerRadius: 8))
}
.disabled(dockerRunCommand.trimmingCharacters(in: .whitespaces).isEmpty || isConverting)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
private var composeSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader("compose.yaml")
TextEditor(text: $composeYAML)
.font(.monoBody)
.foregroundStyle(Color.terminalText)
.scrollContentBackground(.hidden)
.frame(minHeight: 200)
.padding(8)
.background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8))
}
}
private var envSection: some View {
VStack(alignment: .leading, spacing: 12) {
sectionHeader(".env (optional)")
TextEditor(text: $envContent)
.font(.monoBody)
.foregroundStyle(Color.terminalText)
.scrollContentBackground(.hidden)
.frame(minHeight: 80)
.padding(8)
.background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8))
}
}
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Color.appGray)
.textCase(.uppercase)
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarItems: some ToolbarContent {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
Task { await deploy() }
} label: {
Label("Save & Deploy", systemImage: "bolt.fill")
}
Button {
Task { await save() }
} label: {
Label("Save Draft", systemImage: "square.and.arrow.down")
}
} label: {
if isWorking {
ProgressView().scaleEffect(0.8)
} else {
Text("Create").fontWeight(.semibold)
}
}
.disabled(!nameIsValid || isWorking)
}
}
// MARK: - Actions
private func runConverter() async {
let cmd = dockerRunCommand.trimmingCharacters(in: .whitespaces)
guard !cmd.isEmpty else { return }
isConverting = true
errorMessage = nil
if let result = await service.composerize(cmd) {
composeYAML = result
withAnimation { converterExpanded = false }
} else {
errorMessage = "Conversion failed. Check your docker run command."
}
isConverting = false
}
private func save() async {
guard nameIsValid else { return }
isWorking = true
errorMessage = nil
let error = await service.saveStack(
name: stackName.trimmingCharacters(in: .whitespaces),
yaml: composeYAML,
env: envContent,
isAdd: true,
endpoint: selectedEndpoint
)
isWorking = false
if let msg = error {
errorMessage = msg
} else {
dismiss()
}
}
private func deploy() async {
guard nameIsValid else { return }
isWorking = true
errorMessage = nil
let name = stackName.trimmingCharacters(in: .whitespaces)
deployTerminalName = service.terminalName(for: name, endpoint: selectedEndpoint)
service.clearTerminalBuffer(name: deployTerminalName)
let error = await service.deployStack(
name: name,
yaml: composeYAML,
env: envContent,
isAdd: true,
endpoint: selectedEndpoint
)
isWorking = false
if let msg = error {
errorMessage = msg
} else {
showTerminal = true
dismiss()
}
}
}
@@ -0,0 +1,27 @@
import SwiftUI
struct EnvTabView: View {
@Binding var env: String
var isEditing: Bool
var body: some View {
ScrollView {
if isEditing {
TextEditor(text: $env)
.font(.monoBody)
.foregroundStyle(Color.terminalText)
.scrollContentBackground(.hidden)
.frame(minHeight: 400)
.padding(8)
} else {
Text(env.isEmpty ? " " : env)
.font(.monoBody)
.foregroundStyle(Color.terminalText)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
}
}
.background(Color.terminalBg)
}
}
@@ -0,0 +1,102 @@
import SwiftUI
struct LogsTabView: View {
let stackName: String
let endpoint: String
let service: DockgeService
@Environment(AppState.self) private var appState
@State private var displayAttr = AttributedString()
@State private var hasContent = false
@State private var autoScroll = true
@State private var refreshTask: Task<Void, Never>?
@State private var lastUpdated: Date?
private static let timeFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss"
return f
}()
private var terminalName: String {
service.combinedTerminalName(for: stackName, endpoint: endpoint)
}
var body: some View {
VStack(spacing: 0) {
HStack {
if let date = lastUpdated {
Text("Updated \(Self.timeFormatter.string(from: date))")
.font(.system(size: 11))
.foregroundStyle(Color.appGray)
}
Spacer()
Toggle("Auto-scroll", isOn: $autoScroll)
.toggleStyle(.switch)
.tint(.appAccent)
.font(.system(size: 12))
.foregroundStyle(Color.appGray)
Button {
displayAttr = AttributedString()
hasContent = false
service.clearTerminalBuffer(name: terminalName)
} label: {
Image(systemName: "trash")
.font(.system(size: 12))
.foregroundStyle(Color.appGray)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.appSurface)
ScrollViewReader { proxy in
ScrollView {
if hasContent {
Text(displayAttr)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
} else {
Text("Waiting for logs\u{2026}")
.font(.monoSmall)
.foregroundStyle(Color.appGray)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
}
Color.clear.frame(height: 1).id("logEnd")
}
.background(Color.terminalBg)
.onChange(of: displayAttr) { _, _ in
if autoScroll {
withAnimation(.none) {
proxy.scrollTo("logEnd")
}
}
}
}
}
.onAppear {
// Each joinTerminal call delivers the full buffer; re-parse the whole thing.
service.onTerminalWrite(name: terminalName) { fullBuffer in
displayAttr = ANSIParser.attributedString(from: fullBuffer)
hasContent = !fullBuffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
// Initial load + 10-second polling loop
refreshTask = Task {
while !Task.isCancelled {
await service.joinTerminal(stackName: stackName, endpoint: endpoint)
lastUpdated = Date()
try? await Task.sleep(for: .seconds(appState.logRefreshInterval))
}
}
}
.onDisappear {
refreshTask?.cancel()
refreshTask = nil
service.removeTerminalListeners(name: terminalName)
service.leaveTerminal(stackName: stackName, endpoint: endpoint)
}
}
}
@@ -0,0 +1,69 @@
import SwiftUI
struct ServicesTabView: View {
let stackName: String
let endpoint: String
let service: DockgeService
@State private var services: [ServiceStatus] = []
@State private var isLoading = true
var body: some View {
Group {
if isLoading {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if services.isEmpty {
ContentUnavailableView("No Services", systemImage: "cube")
} else {
List(services) { svc in
ServiceRowView(service: svc)
.listRowBackground(Color.appSurface)
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
}
}
.task {
await reload()
}
}
private func reload() async {
isLoading = true
services = await service.serviceStatusList(stackName: stackName, endpoint: endpoint)
isLoading = false
}
}
private struct ServiceRowView: View {
let service: ServiceStatus
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(service.stateColor)
.frame(width: 8, height: 8)
Text(service.name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
Text(service.state)
.font(.system(size: 12))
.foregroundStyle(service.stateColor)
}
if !service.ports.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(service.ports, id: \.self) { port in
Text(port)
.font(.monoSmall)
.foregroundStyle(.appGray)
}
}
.padding(.leading, 16)
}
}
.padding(.vertical, 4)
}
}
@@ -0,0 +1,345 @@
import SwiftUI
struct StackDetailView: View {
let initialStack: Stack
let service: DockgeService
@Environment(\.dismiss) private var dismiss
@State private var stack: Stack
@State private var selectedTab = 0
@State private var isEditing = false
@State private var editYAML = ""
@State private var editENV = ""
@State private var isLoading = true
@State private var actionInProgress: String?
@State private var actionError: String?
@State private var showDeleteConfirmation = false
init(initialStack: Stack, service: DockgeService) {
self.initialStack = initialStack
self.service = service
_stack = State(initialValue: initialStack)
}
// MARK: - Custom floppy disk icon (not in SF Symbols)
private struct FloppyDiskShape: Shape {
func path(in rect: CGRect) -> Path {
let w = rect.width, h = rect.height
let cr = min(w, h) * 0.12 // corner radius (3 rounded corners)
let cut = min(w, h) * 0.20 // top-right diagonal cut
var p = Path()
// Outer body: rounded rect with clipped top-right corner
p.move(to: CGPoint(x: cr, y: 0))
p.addLine(to: CGPoint(x: w - cut, y: 0))
p.addLine(to: CGPoint(x: w, y: cut))
p.addLine(to: CGPoint(x: w, y: h - cr))
p.addArc(center: CGPoint(x: w - cr, y: h - cr), radius: cr,
startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
p.addLine(to: CGPoint(x: cr, y: h))
p.addArc(center: CGPoint(x: cr, y: h - cr), radius: cr,
startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
p.addLine(to: CGPoint(x: 0, y: cr))
p.addArc(center: CGPoint(x: cr, y: cr), radius: cr,
startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
p.closeSubpath()
// Shutter slot cutout at the top (even-odd hole)
let slotX = w * 0.15
let slotW = w - slotX - cut * 0.5
p.addRect(CGRect(x: slotX, y: 0, width: slotW, height: h * 0.42))
// Hub inside shutter small rectangle (3rd level solid again)
let hubW = w * 0.22, hubH = h * 0.20
p.addRect(CGRect(x: (w - hubW) * 0.5, y: h * 0.06, width: hubW, height: hubH))
// Label area at bottom cutout (even-odd hole)
let labInset = w * 0.10
p.addRect(CGRect(x: labInset, y: h * 0.54, width: w - labInset * 2, height: h * 0.34))
return p
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Header
headerSection
// Error banner
if let error = actionError {
Text(error)
.font(.system(size: 13))
.foregroundStyle(.primary)
.padding(10)
.frame(maxWidth: .infinity)
.background(Color.appRed.opacity(0.8))
}
// Tabs
Picker("Tab", selection: $selectedTab) {
Text("Services").tag(0)
Text("Compose").tag(1)
Text("Env").tag(2)
Text("Logs").tag(3)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color.appSurface)
// Tab content
tabContent
}
.background(Color.appBackground)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
HStack(spacing: 6) {
Circle()
.fill(stack.status.color)
.frame(width: 8, height: 8)
Text(stack.name)
.font(.system(size: 16, weight: .bold))
}
}
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: {
Image(systemName: "xmark")
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
if selectedTab == 1 || selectedTab == 2 {
if isEditing {
Button {
Task { await deployChanges() }
} label: {
Image(systemName: "hammer.fill")
}
.disabled(actionInProgress != nil)
Button {
Task { await saveChanges() }
} label: {
FloppyDiskShape()
.fill(style: FillStyle(eoFill: true))
.frame(width: 20, height: 20)
}
.disabled(actionInProgress != nil)
Button {
editYAML = stack.composeYAML ?? ""
editENV = stack.composeENV ?? ""
isEditing = false
} label: {
Image(systemName: "xmark")
}
} else {
Button { isEditing = true } label: {
Image(systemName: "pencil")
}
}
}
}
}
}
.confirmationDialog(
"Delete stack?",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete Stack", role: .destructive) {
Task {
let error = await service.deleteStack(stack.name, endpoint: stack.endpoint)
if error == nil { dismiss() }
if let msg = error { actionError = msg }
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("This will run docker compose down and remove all stack files. This cannot be undone.")
}
.task { await loadDetails() }
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(stack.status.label)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(stack.status.color)
if !stack.primaryHostname.isEmpty {
Text(stack.primaryHostname)
.font(.system(size: 12))
.foregroundStyle(Color.appGray)
}
}
Spacer()
if let action = actionInProgress {
HStack(spacing: 6) {
ProgressView().tint(.appAccent).scaleEffect(0.8)
Text(action + "")
.font(.system(size: 12))
.foregroundStyle(Color.appGray)
}
}
}
// Action buttons
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if stack.status != .running {
actionButton("Start", icon: "play.fill", color: .appGreen) {
await performAction("startStack", display: "Starting")
}
}
if stack.status == .running || stack.status == .unknown || stack.status == .exited {
actionButton("Stop", icon: "stop.fill", color: .appRed) {
await performAction("stopStack", display: "Stopping")
}
actionButton("Restart", icon: "arrow.counterclockwise", color: .appAccent) {
await performAction("restartStack", display: "Restarting")
}
actionButton("Update", icon: "arrow.down.circle", color: .appAccent) {
await performAction("updateStack", display: "Updating")
}
}
actionButton("Down", icon: "arrow.down.to.line", color: .appGray) {
await performAction("downStack", display: "Taking down")
}
actionButton("Delete", icon: "trash", color: .appRed) {
showDeleteConfirmation = true
}
}
.padding(.horizontal, 2)
}
}
.padding()
.background(Color.appSurface)
}
@ViewBuilder
private func actionButton(_ label: String, icon: String, color: Color, action: @escaping () async -> Void) -> some View {
Button {
Task { await action() }
} label: {
Label(label, systemImage: icon)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(color)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
}
.disabled(actionInProgress != nil)
}
// MARK: - Tab content
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
case 0:
ServicesTabView(stackName: stack.name, endpoint: stack.endpoint, service: service)
case 1:
ComposeTabView(yaml: $editYAML, isEditing: isEditing, onSave: saveChanges)
case 2:
EnvTabView(env: $editENV, isEditing: isEditing)
case 3:
LogsTabView(stackName: stack.name, endpoint: stack.endpoint, service: service)
default:
EmptyView()
}
}
// MARK: - Actions
private func loadDetails() async {
isLoading = true
if let detailed = await service.getStack(name: stack.name, endpoint: stack.endpoint) {
// getStack often returns status=0 (unknown) because the agent hasn't
// polled docker compose ps yet. The stackList already has the real status,
// so keep it unless getStack gives us something more specific.
let resolvedStatus = detailed.status == .unknown ? stack.status : detailed.status
stack = Stack(
name: detailed.name,
status: resolvedStatus,
isManagedByDockge: detailed.isManagedByDockge,
composeFileName: detailed.composeFileName,
endpoint: detailed.endpoint,
primaryHostname: detailed.primaryHostname,
composeYAML: detailed.composeYAML,
composeENV: detailed.composeENV,
tags: detailed.tags
)
editYAML = detailed.composeYAML ?? ""
editENV = detailed.composeENV ?? ""
}
isLoading = false
}
private func performAction(_ event: String, display: String) async {
actionInProgress = display
actionError = nil
let error: String?
switch event {
case "startStack": error = await service.startStack(stack.name, endpoint: stack.endpoint)
case "stopStack": error = await service.stopStack(stack.name, endpoint: stack.endpoint)
case "restartStack": error = await service.restartStack(stack.name, endpoint: stack.endpoint)
case "updateStack": error = await service.updateStack(stack.name, endpoint: stack.endpoint)
case "downStack": error = await service.downStack(stack.name, endpoint: stack.endpoint)
default: error = "Unknown action"
}
actionInProgress = nil
if let msg = error { actionError = msg }
await loadDetails()
}
private func saveChanges() async {
actionInProgress = "Saving"
actionError = nil
let error = await service.saveStack(
name: stack.name,
yaml: editYAML,
env: editENV,
isAdd: false,
endpoint: stack.endpoint
)
actionInProgress = nil
if let msg = error {
actionError = msg
} else {
isEditing = false
await loadDetails()
}
}
private func deployChanges() async {
actionInProgress = "Deploying"
actionError = nil
let error = await service.deployStack(
name: stack.name,
yaml: editYAML,
env: editENV,
isAdd: false,
endpoint: stack.endpoint
)
actionInProgress = nil
if let msg = error {
actionError = msg
} else {
isEditing = false
await loadDetails()
}
}
}
@@ -0,0 +1,57 @@
import SwiftUI
struct StackRowView: View, Equatable {
let stack: Stack
private var machineLabel: String {
if stack.endpoint.isEmpty {
return "local"
}
// endpoint is "host:port" show only the host
return stack.endpoint.components(separatedBy: ":").first ?? stack.endpoint
}
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(stack.status.color)
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 2) {
Text(stack.name)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(.primary)
Text(stack.status.label)
.font(.system(size: 12))
.foregroundStyle(.appGray)
}
Spacer()
if !stack.tags.isEmpty {
ForEach(stack.tags.prefix(2), id: \.self) { tag in
Text(tag)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color.appAccent)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appAccent.opacity(0.2), in: Capsule())
.overlay(Capsule().strokeBorder(Color.appAccent.opacity(0.4), lineWidth: 0.5))
}
}
Text(machineLabel)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color.appGray)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.appGray.opacity(0.15), in: Capsule())
.overlay(Capsule().strokeBorder(Color.appGray.opacity(0.35), lineWidth: 0.5))
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.appGray)
}
.padding(.vertical, 4)
}
}
+173
View File
@@ -0,0 +1,173 @@
import SwiftUI
struct StacksView: View {
@Environment(AppState.self) private var appState
@State private var selectedStack: Stack?
@State private var searchText = ""
@State private var showLoginSheet = false
@State private var showCreateSheet = false
/// Sorts and partitions stacks in a single pass called once per render.
private var partitionedStacks: (running: [Stack], inactive: [Stack]) {
let all = searchText.isEmpty
? appState.stacks
: appState.stacks.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
let sorted = all.sorted { a, b in
let ao = sortKey(a.status), bo = sortKey(b.status)
return ao != bo ? ao < bo : a.name < b.name
}
var running: [Stack] = []
var inactive: [Stack] = []
for stack in sorted {
if stack.status == .running { running.append(stack) } else { inactive.append(stack) }
}
return (running, inactive)
}
private func sortKey(_ status: StackStatus) -> Int {
switch status {
case .running: return 0
case .exited: return 1
case .unknown: return 2
case .createdStack: return 3
case .createdFile: return 4
}
}
var body: some View {
NavigationStack {
Group {
if appState.activeServer == nil {
noServerPlaceholder
} else if let service = appState.dockgeService {
switch service.authState {
case .disconnected, .connecting:
connectingView
case .needsLogin, .twoFactorRequired:
Color.clear
.onAppear { showLoginSheet = true }
case .needsSetup:
Color.clear
.onAppear { showLoginSheet = true }
case .authenticated:
stackList
case .error(let msg):
errorView(msg)
}
}
}
.navigationTitle(appState.activeServer?.name ?? "Dockge")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if appState.activeServer != nil, appState.dockgeService?.authState == .authenticated {
Button {
showCreateSheet = true
} label: {
Image(systemName: "plus")
}
}
}
}
.sheet(isPresented: $showLoginSheet) {
if let service = appState.dockgeService {
LoginView(service: service, server: appState.activeServer!)
}
}
.sheet(item: $selectedStack) { stack in
if let service = appState.dockgeService {
StackDetailView(initialStack: stack, service: service)
}
}
.sheet(isPresented: $showCreateSheet) {
if let service = appState.dockgeService {
CreateStackView(service: service)
}
}
}
}
// MARK: - Subviews
private func refreshStacks() async {
guard let service = appState.dockgeService else { return }
if service.authState == .disconnected {
service.reconnect()
try? await Task.sleep(for: .seconds(2))
} else {
service.requestStackList()
try? await Task.sleep(for: .seconds(1))
}
}
private var stackList: some View {
let (running, inactive) = partitionedStacks
return List {
if appState.stacks.isEmpty {
ContentUnavailableView(
"No Stacks",
systemImage: "shippingbox",
description: Text("No stacks found on this server.")
)
} else {
if !running.isEmpty {
Section {
ForEach(running) { stack in
Button { selectedStack = stack } label: {
StackRowView(stack: stack).equatable()
}
.listRowBackground(Color.appSurface)
}
} header: {
Label("\(running.count) Running", systemImage: "circle.fill")
.foregroundStyle(Color.appGreen)
.font(.system(size: 12, weight: .semibold))
}
}
if !inactive.isEmpty {
Section {
ForEach(inactive) { stack in
Button { selectedStack = stack } label: {
StackRowView(stack: stack).equatable()
}
.listRowBackground(Color.appSurface)
}
} header: {
Label("\(inactive.count) Inactive", systemImage: "circle")
.foregroundStyle(Color.appGray)
.font(.system(size: 12, weight: .semibold))
}
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.searchable(text: $searchText, prompt: "Search stacks")
.refreshable { await refreshStacks() }
}
private var noServerPlaceholder: some View {
ContentUnavailableView {
Label("No Server Selected", systemImage: "server.rack")
} description: {
Text("Go to Servers to add and connect to a Dockge instance.")
}
}
private var connectingView: some View {
VStack(spacing: 16) {
ProgressView()
Text("Connecting…")
.foregroundStyle(.appGray)
}
}
private func errorView(_ message: String) -> some View {
ContentUnavailableView {
Label("Connection Error", systemImage: "wifi.exclamationmark")
} description: {
Text(message)
}
}
}
+20
View File
@@ -0,0 +1,20 @@
//
// dock_gApp.swift
// dock-g
//
// Created by Sven Hanold on 10.04.26.
//
import SwiftUI
@main
struct dock_gApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
ContentView()
.environment(appState)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Submodule original-source/dockge added at cc180562fc