commit 2e7e931c3babb25712080bbc554013d0988a42e5 Author: Sven Date: Fri Apr 10 22:30:46 2026 +0200 first commit diff --git a/dock-g/.DS_Store b/dock-g/.DS_Store new file mode 100644 index 0000000..4da5c6b Binary files /dev/null and b/dock-g/.DS_Store differ diff --git a/dock-g/dock-g.xcodeproj/project.pbxproj b/dock-g/dock-g.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0e5c609 --- /dev/null +++ b/dock-g/dock-g.xcodeproj/project.pbxproj @@ -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 = ""; + }; +/* 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 = ""; + }; + 2617D1572F89084500DEE247 /* Products */ = { + isa = PBXGroup; + children = ( + 2617D1562F89084500DEE247 /* dock-g.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/dock-g/dock-g.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/dock-g/dock-g.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/dock-g/dock-g.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate b/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..4200542 Binary files /dev/null and b/dock-g/dock-g.xcodeproj/project.xcworkspace/xcuserdata/sven.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/dock-g/dock-g.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist b/dock-g/dock-g.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..1c47d09 --- /dev/null +++ b/dock-g/dock-g.xcodeproj/xcuserdata/sven.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + dock-g.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/dock-g/dock-g/Assets.xcassets/AccentColor.colorset/Contents.json b/dock-g/dock-g/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/dock-g/dock-g/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..a4b877d Binary files /dev/null and b/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/Contents.json b/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..87d4015 --- /dev/null +++ b/dock-g/dock-g/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/dock-g/dock-g/Assets.xcassets/Contents.json b/dock-g/dock-g/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/dock-g/dock-g/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/dock-g/dock-g/ContentView.swift b/dock-g/dock-g/ContentView.swift new file mode 100644 index 0000000..3fd9c36 --- /dev/null +++ b/dock-g/dock-g/ContentView.swift @@ -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()) +} diff --git a/dock-g/dock-g/Models/DockgeServer.swift b/dock-g/dock-g/Models/DockgeServer.swift new file mode 100644 index 0000000..ccc1561 --- /dev/null +++ b/dock-g/dock-g/Models/DockgeServer.swift @@ -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 } +} diff --git a/dock-g/dock-g/Models/ServiceStatus.swift b/dock-g/dock-g/Models/ServiceStatus.swift new file mode 100644 index 0000000..46a03d9 --- /dev/null +++ b/dock-g/dock-g/Models/ServiceStatus.swift @@ -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") + } + } +} diff --git a/dock-g/dock-g/Models/Stack.swift b/dock-g/dock-g/Models/Stack.swift new file mode 100644 index 0000000..f4de5db --- /dev/null +++ b/dock-g/dock-g/Models/Stack.swift @@ -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 + } +} diff --git a/dock-g/dock-g/Models/StackStatus.swift b/dock-g/dock-g/Models/StackStatus.swift new file mode 100644 index 0000000..5c5f9e3 --- /dev/null +++ b/dock-g/dock-g/Models/StackStatus.swift @@ -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 } +} diff --git a/dock-g/dock-g/Services/DockgeService.swift b/dock-g/dock-g/Services/DockgeService.swift new file mode 100644 index 0000000..3b13375 --- /dev/null +++ b/dock-g/dock-g/Services/DockgeService.swift @@ -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? + + /// 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 + } + } +} diff --git a/dock-g/dock-g/Services/KeychainService.swift b/dock-g/dock-g/Services/KeychainService.swift new file mode 100644 index 0000000..c4e0735 --- /dev/null +++ b/dock-g/dock-g/Services/KeychainService.swift @@ -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)" + } +} diff --git a/dock-g/dock-g/Services/SocketIOClient.swift b/dock-g/dock-g/Services/SocketIOClient.swift new file mode 100644 index 0000000..b6bef78 --- /dev/null +++ b/dock-g/dock-g/Services/SocketIOClient.swift @@ -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.. Void + ) { + if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust { + completionHandler(.useCredential, URLCredential(trust: trust)) + } else { + completionHandler(.performDefaultHandling, nil) + } + } +} diff --git a/dock-g/dock-g/Utilities/ANSIParser.swift b/dock-g/dock-g/Utilities/ANSIParser.swift new file mode 100644 index 0000000..9312f98 --- /dev/null +++ b/dock-g/dock-g/Utilities/ANSIParser.swift @@ -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.. 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 + } +} diff --git a/dock-g/dock-g/Utilities/ANSIStripper.swift b/dock-g/dock-g/Utilities/ANSIStripper.swift new file mode 100644 index 0000000..9356cfe --- /dev/null +++ b/dock-g/dock-g/Utilities/ANSIStripper.swift @@ -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: "") + } +} diff --git a/dock-g/dock-g/Utilities/Theme.swift b/dock-g/dock-g/Utilities/Theme.swift new file mode 100644 index 0000000..e313eda --- /dev/null +++ b/dock-g/dock-g/Utilities/Theme.swift @@ -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) +} diff --git a/dock-g/dock-g/ViewModels/AppState.swift b/dock-g/dock-g/ViewModels/AppState.swift new file mode 100644 index 0000000..c47b0f7 --- /dev/null +++ b/dock-g/dock-g/ViewModels/AppState.swift @@ -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) + } + } +} diff --git a/dock-g/dock-g/Views/Servers/AddServerView.swift b/dock-g/dock-g/Views/Servers/AddServerView.swift new file mode 100644 index 0000000..72d7433 --- /dev/null +++ b/dock-g/dock-g/Views/Servers/AddServerView.swift @@ -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() + } +} diff --git a/dock-g/dock-g/Views/Servers/LoginView.swift b/dock-g/dock-g/Views/Servers/LoginView.swift new file mode 100644 index 0000000..b348a67 --- /dev/null +++ b/dock-g/dock-g/Views/Servers/LoginView.swift @@ -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 + } +} diff --git a/dock-g/dock-g/Views/Servers/ServersView.swift b/dock-g/dock-g/Views/Servers/ServersView.swift new file mode 100644 index 0000000..66f26ee --- /dev/null +++ b/dock-g/dock-g/Views/Servers/ServersView.swift @@ -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) + } + } + } +} diff --git a/dock-g/dock-g/Views/Settings/SettingsView.swift b/dock-g/dock-g/Views/Settings/SettingsView.swift new file mode 100644 index 0000000..97533b7 --- /dev/null +++ b/dock-g/dock-g/Views/Settings/SettingsView.swift @@ -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") + } + + } +} diff --git a/dock-g/dock-g/Views/Stacks/ActionTerminalSheet.swift b/dock-g/dock-g/Views/Stacks/ActionTerminalSheet.swift new file mode 100644 index 0000000..aaacde4 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/ActionTerminalSheet.swift @@ -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) + } + } +} diff --git a/dock-g/dock-g/Views/Stacks/ComposeTabView.swift b/dock-g/dock-g/Views/Stacks/ComposeTabView.swift new file mode 100644 index 0000000..9ec73f8 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/ComposeTabView.swift @@ -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) + } +} diff --git a/dock-g/dock-g/Views/Stacks/CreateStackView.swift b/dock-g/dock-g/Views/Stacks/CreateStackView.swift new file mode 100644 index 0000000..25a2af8 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/CreateStackView.swift @@ -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() + } + } +} diff --git a/dock-g/dock-g/Views/Stacks/EnvTabView.swift b/dock-g/dock-g/Views/Stacks/EnvTabView.swift new file mode 100644 index 0000000..93f2cc7 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/EnvTabView.swift @@ -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) + } +} diff --git a/dock-g/dock-g/Views/Stacks/LogsTabView.swift b/dock-g/dock-g/Views/Stacks/LogsTabView.swift new file mode 100644 index 0000000..5a28936 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/LogsTabView.swift @@ -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? + @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) + } + } +} diff --git a/dock-g/dock-g/Views/Stacks/ServicesTabView.swift b/dock-g/dock-g/Views/Stacks/ServicesTabView.swift new file mode 100644 index 0000000..69692b8 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/ServicesTabView.swift @@ -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) + } +} diff --git a/dock-g/dock-g/Views/Stacks/StackDetailView.swift b/dock-g/dock-g/Views/Stacks/StackDetailView.swift new file mode 100644 index 0000000..4ea7d24 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/StackDetailView.swift @@ -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() + } + } +} diff --git a/dock-g/dock-g/Views/Stacks/StackRowView.swift b/dock-g/dock-g/Views/Stacks/StackRowView.swift new file mode 100644 index 0000000..f0a6f53 --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/StackRowView.swift @@ -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) + } +} diff --git a/dock-g/dock-g/Views/Stacks/StacksView.swift b/dock-g/dock-g/Views/Stacks/StacksView.swift new file mode 100644 index 0000000..e14f3ef --- /dev/null +++ b/dock-g/dock-g/Views/Stacks/StacksView.swift @@ -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) + } + } +} diff --git a/dock-g/dock-g/dock_gApp.swift b/dock-g/dock-g/dock_gApp.swift new file mode 100644 index 0000000..9fda18b --- /dev/null +++ b/dock-g/dock-g/dock_gApp.swift @@ -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) + } + } +} diff --git a/dock-g_ios_icons/dockge_AppIcon_1024.png b/dock-g_ios_icons/dockge_AppIcon_1024.png new file mode 100644 index 0000000..a4b877d Binary files /dev/null and b/dock-g_ios_icons/dockge_AppIcon_1024.png differ diff --git a/dock-g_ios_icons/dockge_AppIcon_20@3x.png b/dock-g_ios_icons/dockge_AppIcon_20@3x.png new file mode 100644 index 0000000..077aa05 Binary files /dev/null and b/dock-g_ios_icons/dockge_AppIcon_20@3x.png differ diff --git a/dock-g_ios_icons/dockge_AppIcon_29@3x.png b/dock-g_ios_icons/dockge_AppIcon_29@3x.png new file mode 100644 index 0000000..6867910 Binary files /dev/null and b/dock-g_ios_icons/dockge_AppIcon_29@3x.png differ diff --git a/dock-g_ios_icons/dockge_AppIcon_40@2x.png b/dock-g_ios_icons/dockge_AppIcon_40@2x.png new file mode 100644 index 0000000..6ee2ab2 Binary files /dev/null and b/dock-g_ios_icons/dockge_AppIcon_40@2x.png differ diff --git a/dock-g_ios_icons/dockge_AppIcon_60@2x.png b/dock-g_ios_icons/dockge_AppIcon_60@2x.png new file mode 100644 index 0000000..e319113 Binary files /dev/null and b/dock-g_ios_icons/dockge_AppIcon_60@2x.png differ diff --git a/dock-g_ios_icons/dockge_AppIcon_60@3x.png b/dock-g_ios_icons/dockge_AppIcon_60@3x.png new file mode 100644 index 0000000..9c6139a Binary files /dev/null and b/dock-g_ios_icons/dockge_AppIcon_60@3x.png differ diff --git a/dock-g_ios_icons/dockge_AppIcon_76@2x.png b/dock-g_ios_icons/dockge_AppIcon_76@2x.png new file mode 100644 index 0000000..f258940 Binary files /dev/null and b/dock-g_ios_icons/dockge_AppIcon_76@2x.png differ diff --git a/dock-g_ios_icons/dockge_AppIcon_83.5@2x.png b/dock-g_ios_icons/dockge_AppIcon_83.5@2x.png new file mode 100644 index 0000000..f36e264 Binary files /dev/null and b/dock-g_ios_icons/dockge_AppIcon_83.5@2x.png differ diff --git a/original-source/dockge b/original-source/dockge new file mode 160000 index 0000000..cc18056 --- /dev/null +++ b/original-source/dockge @@ -0,0 +1 @@ +Subproject commit cc180562fcd534de7c0890633494cde2c9658d97