first commit
@@ -0,0 +1,337 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
2617D1562F89084500DEE247 /* dock-g.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "dock-g.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
2617D1582F89084500DEE247 /* dock-g */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "dock-g";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
2617D1532F89084400DEE247 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2617D14D2F89084400DEE247 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2617D1582F89084500DEE247 /* dock-g */,
|
||||
2617D1572F89084500DEE247 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2617D1572F89084500DEE247 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2617D1562F89084500DEE247 /* dock-g.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
2617D1552F89084400DEE247 /* dock-g */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 2617D1612F89084500DEE247 /* Build configuration list for PBXNativeTarget "dock-g" */;
|
||||
buildPhases = (
|
||||
2617D1522F89084400DEE247 /* Sources */,
|
||||
2617D1532F89084400DEE247 /* Frameworks */,
|
||||
2617D1542F89084400DEE247 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
2617D1582F89084500DEE247 /* dock-g */,
|
||||
);
|
||||
name = "dock-g";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "dock-g";
|
||||
productReference = 2617D1562F89084500DEE247 /* dock-g.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
2617D14E2F89084400DEE247 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2640;
|
||||
LastUpgradeCheck = 2640;
|
||||
TargetAttributes = {
|
||||
2617D1552F89084400DEE247 = {
|
||||
CreatedOnToolsVersion = 26.4;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 2617D1512F89084400DEE247 /* Build configuration list for PBXProject "dock-g" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 2617D14D2F89084400DEE247;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 2617D1572F89084500DEE247 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
2617D1552F89084400DEE247 /* dock-g */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
2617D1542F89084400DEE247 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
2617D1522F89084400DEE247 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
2617D15F2F89084500DEE247 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
2617D1602F89084500DEE247 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.4;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
2617D1622F89084500DEE247 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "Team.dock-g";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
2617D1632F89084500DEE247 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = EKFHUHT63T;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "Team.dock-g";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
2617D1512F89084400DEE247 /* Build configuration list for PBXProject "dock-g" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2617D15F2F89084500DEE247 /* Debug */,
|
||||
2617D1602F89084500DEE247 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
2617D1612F89084500DEE247 /* Build configuration list for PBXNativeTarget "dock-g" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
2617D1622F89084500DEE247 /* Debug */,
|
||||
2617D1632F89084500DEE247 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 2617D14E2F89084400DEE247 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>dock-g.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import Foundation
|
||||
|
||||
// All Dockge-specific Socket.IO operations for a single server connection.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class DockgeService {
|
||||
|
||||
enum AuthState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case needsLogin
|
||||
case needsSetup
|
||||
case twoFactorRequired
|
||||
case authenticated
|
||||
case error(String)
|
||||
}
|
||||
|
||||
var authState: AuthState = .disconnected
|
||||
var serverInfo: ServerInfo?
|
||||
/// Stack list — updated directly from the "stackList" socket event.
|
||||
var stacks: [Stack] = []
|
||||
|
||||
struct ServerInfo {
|
||||
var version: String
|
||||
var primaryHostname: String
|
||||
}
|
||||
|
||||
private let socket: SocketIOClient
|
||||
private let server: DockgeServer
|
||||
|
||||
// Terminal buffers keyed by terminalName
|
||||
private(set) var terminalBuffers: [String: String] = [:]
|
||||
private var terminalListeners: [String: [(String) -> Void]] = [:]
|
||||
/// Stacks indexed by agent endpoint so partial updates don't wipe other agents.
|
||||
private var stacksByEndpoint: [String: [Stack]] = [:]
|
||||
|
||||
// Reconnect state
|
||||
private var intentionalDisconnect = false
|
||||
private var reconnectDelay: TimeInterval = 1
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
|
||||
/// All known agent endpoints (empty string = local agent).
|
||||
var availableEndpoints: [String] {
|
||||
Array(stacksByEndpoint.keys).sorted()
|
||||
}
|
||||
|
||||
init(server: DockgeServer) {
|
||||
self.server = server
|
||||
self.socket = SocketIOClient(allowSelfSigned: true)
|
||||
}
|
||||
|
||||
// MARK: - Connection
|
||||
|
||||
func connect() {
|
||||
intentionalDisconnect = false
|
||||
reconnectDelay = 1
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
setupHandlers()
|
||||
doConnect()
|
||||
}
|
||||
|
||||
/// Reconnect after an unexpected drop (e.g. from pull-to-refresh or explicit retry).
|
||||
func reconnect() {
|
||||
guard !intentionalDisconnect else { return }
|
||||
reconnectDelay = 1
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
doConnect()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
intentionalDisconnect = true
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
socket.clearHandlers()
|
||||
socket.disconnect()
|
||||
authState = .disconnected
|
||||
stacks = []
|
||||
stacksByEndpoint = [:]
|
||||
}
|
||||
|
||||
// MARK: - Private connection helpers
|
||||
|
||||
private func setupHandlers() {
|
||||
socket.clearHandlers()
|
||||
|
||||
socket.onConnect { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
print("[DockgeService] Socket connected")
|
||||
self?.reconnectDelay = 1 // reset backoff on success
|
||||
await self?.onSocketConnected()
|
||||
}
|
||||
}
|
||||
socket.onDisconnect { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
print("[DockgeService] Socket disconnected")
|
||||
self.authState = .disconnected
|
||||
if !self.intentionalDisconnect {
|
||||
self.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
socket.onError { error in
|
||||
print("[DockgeService] Socket error: \(error)")
|
||||
}
|
||||
socket.on("agent") { [weak self] data in
|
||||
self?.handleAgentEvent(data)
|
||||
}
|
||||
socket.on("stackList") { [weak self] data in
|
||||
print("[DockgeService] Received legacy stackList event")
|
||||
self?.handleLegacyStackList(data)
|
||||
}
|
||||
socket.on("terminalWrite") { [weak self] data in
|
||||
self?.handleTerminalWrite(data)
|
||||
}
|
||||
socket.on("info") { [weak self] data in
|
||||
self?.handleServerInfo(data)
|
||||
}
|
||||
}
|
||||
|
||||
private func doConnect() {
|
||||
guard let url = server.baseURL else {
|
||||
authState = .error("Invalid server URL")
|
||||
return
|
||||
}
|
||||
authState = .connecting
|
||||
print("[DockgeService] Connecting to \(url) (reconnectDelay was \(reconnectDelay)s)")
|
||||
socket.connect(to: url)
|
||||
}
|
||||
|
||||
private func scheduleReconnect() {
|
||||
let delay = reconnectDelay
|
||||
reconnectDelay = min(reconnectDelay * 2, 30)
|
||||
print("[DockgeService] Scheduling reconnect in \(delay)s")
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, !self.intentionalDisconnect else { return }
|
||||
self.doConnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auth
|
||||
|
||||
private func onSocketConnected() async {
|
||||
if let token = KeychainService.load(forKey: KeychainService.tokenKey(for: server.id)) {
|
||||
print("[DockgeService] Trying loginByToken")
|
||||
let result = await socket.emitWithAck("loginByToken", token)
|
||||
print("[DockgeService] loginByToken result: \(result)")
|
||||
if let dict = result.first as? [String: Any], dict["ok"] as? Bool == true {
|
||||
authState = .authenticated
|
||||
requestStackList()
|
||||
return
|
||||
}
|
||||
KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id))
|
||||
}
|
||||
authState = .needsLogin
|
||||
}
|
||||
|
||||
/// Returns nil on success, error message on failure.
|
||||
func login(username: String, password: String, twoFAToken: String? = nil) async -> String? {
|
||||
var payload: [String: Any] = ["username": username, "password": password]
|
||||
if let t = twoFAToken { payload["token"] = t }
|
||||
let result = await socket.emitWithAck("login", payload)
|
||||
print("[DockgeService] login result: \(result)")
|
||||
guard let dict = result.first as? [String: Any] else { return "No response from server" }
|
||||
|
||||
if dict["tokenRequired"] as? Bool == true {
|
||||
authState = .twoFactorRequired
|
||||
return "2fa"
|
||||
}
|
||||
if dict["ok"] as? Bool == true, let token = dict["token"] as? String {
|
||||
KeychainService.save(token, forKey: KeychainService.tokenKey(for: server.id))
|
||||
authState = .authenticated
|
||||
requestStackList()
|
||||
return nil
|
||||
}
|
||||
return localizedError(dict["msg"] as? String ?? "Login failed")
|
||||
}
|
||||
|
||||
func setup(username: String, password: String) async -> String? {
|
||||
let result = await socket.emitWithAck("setup", username, password)
|
||||
guard let dict = result.first as? [String: Any] else { return "No response" }
|
||||
if dict["ok"] as? Bool == true {
|
||||
return await login(username: username, password: password)
|
||||
}
|
||||
return dict["msg"] as? String ?? "Setup failed"
|
||||
}
|
||||
|
||||
func logout() {
|
||||
KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id))
|
||||
authState = .needsLogin
|
||||
}
|
||||
|
||||
// MARK: - Stack list
|
||||
|
||||
/// Fire-and-forget: Dockge responds via a "stackList" broadcast event, not an ack.
|
||||
func requestStackList() {
|
||||
print("[DockgeService] Emitting requestStackList")
|
||||
socket.emit("requestStackList")
|
||||
}
|
||||
|
||||
// MARK: - Stack operations (return nil on success, error string on failure)
|
||||
|
||||
func getStack(name: String, endpoint: String) async -> Stack? {
|
||||
print("[DockgeService] getStack '\(name)' endpoint='\(endpoint)'")
|
||||
// All stack ops go through the agent proxy: emit("agent", endpoint, event, ...args)
|
||||
let result = await socket.emitWithAck("agent", endpoint, "getStack", name)
|
||||
print("[DockgeService] getStack result: \(result)")
|
||||
guard let dict = result.first as? [String: Any],
|
||||
dict["ok"] as? Bool == true,
|
||||
let stackDict = dict["stack"] as? [String: Any] else { return nil }
|
||||
return parseDetailedStack(stackDict)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func startStack(_ name: String, endpoint: String) async -> String? {
|
||||
return await stackAction("startStack", stackName: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func stopStack(_ name: String, endpoint: String) async -> String? {
|
||||
return await stackAction("stopStack", stackName: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func restartStack(_ name: String, endpoint: String) async -> String? {
|
||||
return await stackAction("restartStack", stackName: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateStack(_ name: String, endpoint: String) async -> String? {
|
||||
return await stackAction("updateStack", stackName: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func downStack(_ name: String, endpoint: String) async -> String? {
|
||||
return await stackAction("downStack", stackName: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func deleteStack(_ name: String, endpoint: String) async -> String? {
|
||||
return await stackAction("deleteStack", stackName: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func deployStack(name: String, yaml: String, env: String, isAdd: Bool, endpoint: String) async -> String? {
|
||||
let result = await socket.emitWithAck("agent", endpoint, "deployStack", name, yaml, env, isAdd)
|
||||
return parseOkResult(result)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func saveStack(name: String, yaml: String, env: String, isAdd: Bool, endpoint: String) async -> String? {
|
||||
let result = await socket.emitWithAck("agent", endpoint, "saveStack", name, yaml, env, isAdd)
|
||||
return parseOkResult(result)
|
||||
}
|
||||
|
||||
/// Convert a `docker run` command to a compose.yaml snippet.
|
||||
/// Handled by the main server (not agent proxy).
|
||||
func composerize(_ dockerRunCommand: String) async -> String? {
|
||||
let result = await socket.emitWithAck("composerize", dockerRunCommand)
|
||||
guard let dict = result.first as? [String: Any],
|
||||
dict["ok"] as? Bool == true,
|
||||
let content = dict["content"] as? String else { return nil }
|
||||
return content
|
||||
}
|
||||
|
||||
func serviceStatusList(stackName: String, endpoint: String) async -> [ServiceStatus] {
|
||||
print("[DockgeService] serviceStatusList '\(stackName)' endpoint='\(endpoint)'")
|
||||
let result = await socket.emitWithAck("agent", endpoint, "serviceStatusList", stackName)
|
||||
print("[DockgeService] serviceStatusList result: \(result)")
|
||||
guard let dict = result.first as? [String: Any],
|
||||
dict["ok"] as? Bool == true,
|
||||
let statusDict = dict["serviceStatusList"] as? [String: Any] else { return [] }
|
||||
return statusDict.map { (svcName, value) -> ServiceStatus in
|
||||
let info = value as? [String: Any] ?? [:]
|
||||
return ServiceStatus(
|
||||
name: svcName,
|
||||
state: info["state"] as? String ?? "unknown",
|
||||
ports: info["ports"] as? [String] ?? []
|
||||
)
|
||||
}.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
// MARK: - Terminal
|
||||
|
||||
func onTerminalWrite(name: String, handler: @escaping (String) -> Void) {
|
||||
terminalListeners[name, default: []].append(handler)
|
||||
if let existing = terminalBuffers[name], !existing.isEmpty {
|
||||
handler(existing)
|
||||
}
|
||||
}
|
||||
|
||||
func removeTerminalListeners(name: String) {
|
||||
terminalListeners.removeValue(forKey: name)
|
||||
}
|
||||
|
||||
func clearTerminalBuffer(name: String) {
|
||||
terminalBuffers.removeValue(forKey: name)
|
||||
}
|
||||
|
||||
func terminalName(for stackName: String, endpoint: String = "") -> String {
|
||||
"compose-\(endpoint)-\(stackName)"
|
||||
}
|
||||
|
||||
func combinedTerminalName(for stackName: String, endpoint: String = "") -> String {
|
||||
"combined-\(endpoint)-\(stackName)"
|
||||
}
|
||||
|
||||
/// Fetch the action terminal buffer (compose/start/stop/restart/update/down) via terminalJoin.
|
||||
func joinActionTerminal(stackName: String, endpoint: String) async {
|
||||
let termName = terminalName(for: stackName, endpoint: endpoint)
|
||||
print("[DockgeService] terminalJoin action '\(termName)'")
|
||||
let result = await socket.emitWithAck("agent", endpoint, "terminalJoin", termName)
|
||||
let buffer: String?
|
||||
if let dict = result.first as? [String: Any] {
|
||||
buffer = dict["buffer"] as? String
|
||||
} else {
|
||||
buffer = result.first as? String
|
||||
}
|
||||
guard let buf = buffer, !buf.isEmpty else { return }
|
||||
terminalBuffers[termName] = buf
|
||||
terminalListeners[termName]?.forEach { $0(buf) }
|
||||
}
|
||||
|
||||
/// Fetch (or re-fetch) combined logs for a stack via terminalJoin.
|
||||
/// Replaces the local buffer with the server's authoritative full buffer,
|
||||
/// then calls all registered listeners with the full content.
|
||||
/// Safe to call repeatedly for polling — each call gives a fresh snapshot.
|
||||
func joinTerminal(stackName: String, endpoint: String) async {
|
||||
let termName = combinedTerminalName(for: stackName, endpoint: endpoint)
|
||||
print("[DockgeService] terminalJoin '\(termName)'")
|
||||
let result = await socket.emitWithAck("agent", endpoint, "terminalJoin", termName)
|
||||
let buffer: String?
|
||||
if let dict = result.first as? [String: Any] {
|
||||
buffer = dict["buffer"] as? String
|
||||
} else {
|
||||
buffer = result.first as? String
|
||||
}
|
||||
guard let buf = buffer else { return }
|
||||
// Replace (not append) so repeated polls give the correct full snapshot
|
||||
terminalBuffers[termName] = buf
|
||||
terminalListeners[termName]?.forEach { $0(buf) }
|
||||
}
|
||||
|
||||
func leaveTerminal(stackName: String, endpoint: String) {
|
||||
socket.emit("agent", endpoint, "leaveCombinedTerminal", stackName)
|
||||
}
|
||||
|
||||
// MARK: - Private event handlers
|
||||
|
||||
/// Handles Dockge 1.5+ multi-agent "agent" events.
|
||||
/// Format: args = ["subCommand", {"ok": true, ...}]
|
||||
private func handleAgentEvent(_ data: [Any]) {
|
||||
guard let subCommand = data.first as? String else { return }
|
||||
let rest = Array(data.dropFirst())
|
||||
print("[DockgeService] agent sub-command: '\(subCommand)'")
|
||||
|
||||
switch subCommand {
|
||||
case "stackList":
|
||||
guard let payload = rest.first as? [String: Any],
|
||||
payload["ok"] as? Bool == true,
|
||||
let stackDict = payload["stackList"] as? [String: Any] else {
|
||||
print("[DockgeService] agent stackList: bad payload: \(rest.first ?? "nil")")
|
||||
return
|
||||
}
|
||||
var result: [Stack] = []
|
||||
var endpointKey = ""
|
||||
for (stackName, stackValue) in stackDict {
|
||||
guard let info = stackValue as? [String: Any] else { continue }
|
||||
let statusRaw = info["status"] as? Int ?? 0
|
||||
let ep = info["endpoint"] as? String ?? ""
|
||||
endpointKey = ep
|
||||
let stack = Stack(
|
||||
name: stackName,
|
||||
status: StackStatus(rawValue: statusRaw) ?? .unknown,
|
||||
isManagedByDockge: info["isManagedByDockge"] as? Bool ?? true,
|
||||
composeFileName: info["composeFileName"] as? String ?? "compose.yaml",
|
||||
endpoint: ep,
|
||||
primaryHostname: info["primaryHostname"] as? String ?? "",
|
||||
tags: info["tags"] as? [String] ?? []
|
||||
)
|
||||
result.append(stack)
|
||||
}
|
||||
// Update only this agent's stacks, preserve other agents
|
||||
stacksByEndpoint[endpointKey] = result
|
||||
rebuildStacks()
|
||||
#if DEBUG
|
||||
print("[DockgeService] agent stackList for endpoint '\(endpointKey)', count: \(result.count), total: \(stacks.count)")
|
||||
#endif
|
||||
|
||||
default:
|
||||
print("[DockgeService] Unhandled agent sub-command: '\(subCommand)', data: \(rest)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy Dockge format: outer dict keyed by endpoint → inner dict keyed by stackName.
|
||||
private func handleLegacyStackList(_ data: [Any]) {
|
||||
guard let outerDict = data.first as? [String: Any] else { return }
|
||||
for (endpoint, endpointValue) in outerDict {
|
||||
guard let stacksDict = endpointValue as? [String: Any] else { continue }
|
||||
var result: [Stack] = []
|
||||
for (stackName, stackValue) in stacksDict {
|
||||
guard let info = stackValue as? [String: Any] else { continue }
|
||||
let statusRaw = info["status"] as? Int ?? 0
|
||||
result.append(Stack(
|
||||
name: stackName,
|
||||
status: StackStatus(rawValue: statusRaw) ?? .unknown,
|
||||
isManagedByDockge: info["isManagedByDockge"] as? Bool ?? true,
|
||||
composeFileName: info["composeFileName"] as? String ?? "compose.yaml",
|
||||
endpoint: info["endpoint"] as? String ?? "",
|
||||
primaryHostname: info["primaryHostname"] as? String ?? "",
|
||||
tags: info["tags"] as? [String] ?? []
|
||||
))
|
||||
}
|
||||
stacksByEndpoint[endpoint] = result
|
||||
}
|
||||
rebuildStacks()
|
||||
print("[DockgeService] legacy stackList, total: \(stacks.count)")
|
||||
}
|
||||
|
||||
private func rebuildStacks() {
|
||||
let rebuilt = stacksByEndpoint.values.flatMap { $0 }.sorted { $0.name < $1.name }
|
||||
guard rebuilt != stacks else { return }
|
||||
stacks = rebuilt
|
||||
}
|
||||
|
||||
private func handleTerminalWrite(_ data: [Any]) {
|
||||
guard let dict = data.first as? [String: Any],
|
||||
let termName = dict["terminalName"] as? String,
|
||||
let chunk = dict["data"] as? String else { return }
|
||||
terminalBuffers[termName, default: ""] += chunk
|
||||
terminalListeners[termName]?.forEach { $0(chunk) }
|
||||
}
|
||||
|
||||
private func handleServerInfo(_ data: [Any]) {
|
||||
guard let dict = data.first as? [String: Any] else { return }
|
||||
serverInfo = ServerInfo(
|
||||
version: dict["version"] as? String ?? "",
|
||||
primaryHostname: dict["primaryHostname"] as? String ?? ""
|
||||
)
|
||||
print("[DockgeService] Server info: v\(serverInfo?.version ?? "")")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func stackAction(_ event: String, stackName: String, endpoint: String) async -> String? {
|
||||
let result = await socket.emitWithAck("agent", endpoint, event, stackName)
|
||||
return parseOkResult(result)
|
||||
}
|
||||
|
||||
private func parseOkResult(_ result: [Any]) -> String? {
|
||||
guard let dict = result.first as? [String: Any] else { return "No response from server" }
|
||||
if dict["ok"] as? Bool == true { return nil }
|
||||
return localizedError(dict["msg"] as? String ?? "Unknown error")
|
||||
}
|
||||
|
||||
private func parseDetailedStack(_ dict: [String: Any]) -> Stack {
|
||||
let statusRaw = dict["status"] as? Int ?? 0
|
||||
return Stack(
|
||||
name: dict["name"] as? String ?? "",
|
||||
status: StackStatus(rawValue: statusRaw) ?? .unknown,
|
||||
isManagedByDockge: dict["isManagedByDockge"] as? Bool ?? true,
|
||||
composeFileName: dict["composeFileName"] as? String ?? "compose.yaml",
|
||||
endpoint: dict["endpoint"] as? String ?? "",
|
||||
primaryHostname: dict["primaryHostname"] as? String ?? "",
|
||||
composeYAML: dict["composeYAML"] as? String,
|
||||
composeENV: dict["composeENV"] as? String,
|
||||
tags: dict["tags"] as? [String] ?? []
|
||||
)
|
||||
}
|
||||
|
||||
private func localizedError(_ key: String) -> String {
|
||||
switch key {
|
||||
case "authIncorrectCreds": return "Incorrect username or password."
|
||||
case "authInvalidToken": return "Session expired. Please log in again."
|
||||
case "authUserInactiveOrDeleted": return "Account is inactive or deleted."
|
||||
case "Stack not found": return "Stack not found."
|
||||
case "Stack name already exists": return "A stack with that name already exists."
|
||||
default: return key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainService {
|
||||
static func save(_ value: String, forKey key: String) {
|
||||
let data = Data(value.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
static func load(forKey key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess, let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
static func delete(forKey key: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
|
||||
static func tokenKey(for serverID: UUID) -> String {
|
||||
"dockge.token.\(serverID.uuidString)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
|
||||
// Minimal Socket.IO v4 / Engine.IO v4 client over WebSocket.
|
||||
// No external dependencies — uses URLSessionWebSocketTask.
|
||||
@MainActor
|
||||
final class SocketIOClient: NSObject {
|
||||
|
||||
enum ConnectionState { case disconnected, connecting, connected }
|
||||
|
||||
private(set) var connectionState: ConnectionState = .disconnected
|
||||
|
||||
private var webSocketTask: URLSessionWebSocketTask?
|
||||
private var urlSession: URLSession!
|
||||
private var nextAckId = 0
|
||||
private var pendingAcks: [Int: ([Any]) -> Void] = [:]
|
||||
private var eventHandlers: [String: [([Any]) -> Void]] = [:]
|
||||
private var connectHandlers: [() -> Void] = []
|
||||
private var disconnectHandlers: [() -> Void] = []
|
||||
private var errorHandlers: [(Error) -> Void] = []
|
||||
private var allowSelfSigned: Bool
|
||||
|
||||
init(allowSelfSigned: Bool = false) {
|
||||
self.allowSelfSigned = allowSelfSigned
|
||||
super.init()
|
||||
let delegate = allowSelfSigned ? InsecureDelegate() : nil
|
||||
urlSession = URLSession(
|
||||
configuration: .default,
|
||||
delegate: delegate,
|
||||
delegateQueue: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
func connect(to baseURL: URL) {
|
||||
guard connectionState == .disconnected else { return }
|
||||
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
|
||||
components.scheme = (baseURL.scheme == "https" || baseURL.scheme == "wss") ? "wss" : "ws"
|
||||
components.path = "/socket.io/"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "EIO", value: "4"),
|
||||
URLQueryItem(name: "transport", value: "websocket")
|
||||
]
|
||||
guard let wsURL = components.url else { return }
|
||||
connectionState = .connecting
|
||||
webSocketTask = urlSession.webSocketTask(with: wsURL)
|
||||
webSocketTask?.resume()
|
||||
scheduleReceive()
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
connectionState = .disconnected
|
||||
webSocketTask?.cancel(with: .normalClosure, reason: nil)
|
||||
webSocketTask = nil
|
||||
pendingAcks.removeAll()
|
||||
disconnectHandlers.forEach { $0() }
|
||||
}
|
||||
|
||||
func on(_ event: String, handler: @escaping ([Any]) -> Void) {
|
||||
eventHandlers[event, default: []].append(handler)
|
||||
}
|
||||
|
||||
func onConnect(handler: @escaping () -> Void) {
|
||||
connectHandlers.append(handler)
|
||||
}
|
||||
|
||||
func onDisconnect(handler: @escaping () -> Void) {
|
||||
disconnectHandlers.append(handler)
|
||||
}
|
||||
|
||||
func onError(handler: @escaping (Error) -> Void) {
|
||||
errorHandlers.append(handler)
|
||||
}
|
||||
|
||||
func clearHandlers() {
|
||||
eventHandlers.removeAll()
|
||||
connectHandlers.removeAll()
|
||||
disconnectHandlers.removeAll()
|
||||
errorHandlers.removeAll()
|
||||
}
|
||||
|
||||
/// Fire-and-forget emit.
|
||||
func emit(_ event: String, _ args: Any...) {
|
||||
sendEvent(event, args: args, ackId: nil)
|
||||
}
|
||||
|
||||
/// Emit with acknowledgement callback.
|
||||
func emitWithAck(_ event: String, _ args: Any..., timeout: TimeInterval = 30) async -> [Any] {
|
||||
await withCheckedContinuation { continuation in
|
||||
let ackId = nextAckId
|
||||
nextAckId += 1
|
||||
var settled = false
|
||||
|
||||
pendingAcks[ackId] = { data in
|
||||
guard !settled else { return }
|
||||
settled = true
|
||||
continuation.resume(returning: data)
|
||||
}
|
||||
sendEvent(event, args: args, ackId: ackId)
|
||||
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
await MainActor.run {
|
||||
guard let self, !settled else { return }
|
||||
settled = true
|
||||
self.pendingAcks.removeValue(forKey: ackId)
|
||||
continuation.resume(returning: [])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private send
|
||||
|
||||
private func sendEvent(_ event: String, args: [Any], ackId: Int?) {
|
||||
var payload: [Any] = [event]
|
||||
payload.append(contentsOf: args)
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let jsonStr = String(data: jsonData, encoding: .utf8) else { return }
|
||||
let ackStr = ackId.map { String($0) } ?? ""
|
||||
sendRaw("42\(ackStr)\(jsonStr)")
|
||||
}
|
||||
|
||||
private func sendRaw(_ text: String) {
|
||||
#if DEBUG
|
||||
print("[SocketIO] TX: \(text.prefix(200))")
|
||||
#endif
|
||||
webSocketTask?.send(.string(text)) { error in
|
||||
if let error {
|
||||
print("[SocketIO] send error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Receive loop
|
||||
|
||||
private func scheduleReceive() {
|
||||
webSocketTask?.receive { [weak self] result in
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
let text: String
|
||||
switch message {
|
||||
case .string(let s): text = s
|
||||
case .data(let d): text = String(data: d, encoding: .utf8) ?? ""
|
||||
@unknown default: text = ""
|
||||
}
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleRaw(text)
|
||||
self?.scheduleReceive()
|
||||
}
|
||||
case .failure(let error):
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.connectionState = .disconnected
|
||||
self.errorHandlers.forEach { $0(error) }
|
||||
self.disconnectHandlers.forEach { $0() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protocol handling
|
||||
|
||||
private func handleRaw(_ text: String) {
|
||||
guard let first = text.first else { return }
|
||||
let body = String(text.dropFirst())
|
||||
#if DEBUG
|
||||
print("[SocketIO] RX: \(text.prefix(200))")
|
||||
#endif
|
||||
switch first {
|
||||
case "0": // EIO OPEN
|
||||
sendRaw("40") // SIO CONNECT to default namespace
|
||||
case "2": // EIO PING → respond PONG
|
||||
sendRaw("3")
|
||||
case "4": // EIO MESSAGE → Socket.IO packet
|
||||
handleSIOPacket(body)
|
||||
case "1": // EIO CLOSE
|
||||
disconnect()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSIOPacket(_ text: String) {
|
||||
guard let first = text.first else { return }
|
||||
let body = String(text.dropFirst())
|
||||
switch first {
|
||||
case "0": // SIO CONNECT
|
||||
connectionState = .connected
|
||||
connectHandlers.forEach { $0() }
|
||||
case "2": // SIO EVENT
|
||||
dispatchEvent(body)
|
||||
case "3": // SIO ACK
|
||||
dispatchAck(body)
|
||||
case "4": // SIO CONNECT_ERROR
|
||||
print("[SocketIO] connect error: \(body)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func dispatchEvent(_ text: String) {
|
||||
// Optional ack ID before the '[' bracket
|
||||
let (jsonStr, _) = splitAckPrefix(text)
|
||||
guard let data = jsonStr.data(using: .utf8),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
let name = array.first as? String else { return }
|
||||
let args = Array(array.dropFirst())
|
||||
eventHandlers[name]?.forEach { $0(args) }
|
||||
}
|
||||
|
||||
private func dispatchAck(_ text: String) {
|
||||
let (jsonStr, ackId) = splitAckPrefix(text)
|
||||
guard let id = ackId,
|
||||
let data = jsonStr.data(using: .utf8),
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
let handler = pendingAcks[id] else { return }
|
||||
pendingAcks.removeValue(forKey: id)
|
||||
handler(array)
|
||||
}
|
||||
|
||||
/// Splits "N[…]" into ("[…]", N), or text that starts with "[" into (text, nil).
|
||||
private func splitAckPrefix(_ text: String) -> (String, Int?) {
|
||||
guard let bracketIdx = text.firstIndex(of: "[") else { return (text, nil) }
|
||||
let prefix = String(text[text.startIndex..<bracketIdx])
|
||||
let json = String(text[bracketIdx...])
|
||||
return (json, Int(prefix))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Self-signed certificate delegate
|
||||
|
||||
private final class InsecureDelegate: NSObject, URLSessionDelegate {
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Parses ANSI/VT100 escape sequences and returns a styled AttributedString.
|
||||
/// SGR color/bold/dim codes are applied as SwiftUI attributes.
|
||||
/// All other escape sequences (cursor movement, erase, etc.) are silently stripped.
|
||||
enum ANSIParser {
|
||||
|
||||
// Matches any ANSI/VT100 escape sequence
|
||||
private static let escapeRegex = try! NSRegularExpression(
|
||||
pattern: #"\x1B(?:[@-Z\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)"#
|
||||
)
|
||||
// Checks if a matched sequence is an SGR sequence (ends with 'm')
|
||||
private static let sgrRegex = try! NSRegularExpression(
|
||||
pattern: #"^\x1B\[([0-9;]*)m$"#
|
||||
)
|
||||
|
||||
// Current SGR render state
|
||||
private struct Style {
|
||||
var foreground: Color? = nil
|
||||
var bold: Bool = false
|
||||
var dim: Bool = false
|
||||
|
||||
mutating func reset() { self = Style() }
|
||||
|
||||
mutating func apply(_ code: Int) {
|
||||
switch code {
|
||||
case 0: reset()
|
||||
case 1: bold = true
|
||||
case 2: dim = true
|
||||
case 22: bold = false; dim = false
|
||||
case 39: foreground = nil
|
||||
// Standard foreground colors (dark-theme optimized)
|
||||
case 30: foreground = Color(white: 0.35)
|
||||
case 31: foreground = Color(hex: "#f87171") // red
|
||||
case 32: foreground = Color(hex: "#4ade80") // green
|
||||
case 33: foreground = Color(hex: "#facc15") // yellow
|
||||
case 34: foreground = Color(hex: "#60a5fa") // blue
|
||||
case 35: foreground = Color(hex: "#e879f9") // magenta
|
||||
case 36: foreground = Color(hex: "#22d3ee") // cyan
|
||||
case 37: foreground = Color(white: 0.88) // white
|
||||
// Bright foreground colors
|
||||
case 90: foreground = Color(white: 0.50)
|
||||
case 91: foreground = Color(hex: "#fca5a5")
|
||||
case 92: foreground = Color(hex: "#86efac")
|
||||
case 93: foreground = Color(hex: "#fde68a")
|
||||
case 94: foreground = Color(hex: "#93c5fd")
|
||||
case 95: foreground = Color(hex: "#f5d0fe")
|
||||
case 96: foreground = Color(hex: "#a5f3fc")
|
||||
case 97: foreground = Color(white: 0.97)
|
||||
default: break // background colors and other codes ignored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
static func attributedString(from input: String) -> AttributedString {
|
||||
// Normalise line endings: \r\n → \n, stray \r → \n
|
||||
let text = input
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
|
||||
var result = AttributedString()
|
||||
var style = Style()
|
||||
|
||||
let nsText = text as NSString
|
||||
let fullRange = NSRange(location: 0, length: nsText.length)
|
||||
let matches = escapeRegex.matches(in: text, range: fullRange)
|
||||
|
||||
var lastIdx = text.startIndex
|
||||
|
||||
for match in matches {
|
||||
guard let matchRange = Range(match.range, in: text) else { continue }
|
||||
|
||||
// Append plain text before this escape sequence
|
||||
if lastIdx < matchRange.lowerBound {
|
||||
result += segment(String(text[lastIdx..<matchRange.lowerBound]), style: style)
|
||||
}
|
||||
|
||||
// Is it an SGR sequence? Apply its codes to style.
|
||||
let seq = String(text[matchRange])
|
||||
let seqNS = seq as NSString
|
||||
let seqRange = NSRange(location: 0, length: seqNS.length)
|
||||
if let sgr = sgrRegex.firstMatch(in: seq, range: seqRange),
|
||||
let codeRange = Range(sgr.range(at: 1), in: seq) {
|
||||
let codeStr = String(seq[codeRange])
|
||||
if codeStr.isEmpty {
|
||||
style.apply(0)
|
||||
} else {
|
||||
codeStr.split(separator: ";").compactMap { Int($0) }.forEach { style.apply($0) }
|
||||
}
|
||||
}
|
||||
// Non-SGR sequences are dropped (cursor movement, clear screen, etc.)
|
||||
|
||||
lastIdx = matchRange.upperBound
|
||||
}
|
||||
|
||||
// Remaining text after the last escape sequence
|
||||
if lastIdx < text.endIndex {
|
||||
result += segment(String(text[lastIdx...]), style: style)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func segment(_ text: String, style: Style) -> AttributedString {
|
||||
var s = AttributedString(text)
|
||||
s.font = .system(
|
||||
size: 12,
|
||||
weight: style.bold ? .semibold : .regular,
|
||||
design: .monospaced
|
||||
)
|
||||
let base: Color = style.foreground ?? Color(hex: "#cbd5e1") // default: slate-300
|
||||
s.foregroundColor = style.dim ? base.opacity(0.5) : base
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum ANSIStripper {
|
||||
// Strips common ANSI/VT100 escape codes from terminal output.
|
||||
private static let pattern = #"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*\x07)"#
|
||||
private static let regex = try! NSRegularExpression(pattern: pattern)
|
||||
|
||||
static func strip(_ input: String) -> String {
|
||||
let range = NSRange(input.startIndex..., in: input)
|
||||
return regex.stringByReplacingMatches(in: input, range: range, withTemplate: "")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppearanceMode: String, CaseIterable {
|
||||
case system, light, dark
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: return "System"
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return .light
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Central application state — inject via .environment(appState)
|
||||
@Observable
|
||||
final class AppState {
|
||||
|
||||
// MARK: - Servers
|
||||
var servers: [DockgeServer] = [] {
|
||||
didSet { persistServers() }
|
||||
}
|
||||
|
||||
// MARK: - Active connection
|
||||
var activeServer: DockgeServer?
|
||||
var dockgeService: DockgeService?
|
||||
|
||||
/// Stacks are owned by DockgeService; this computed property bridges to SwiftUI observation.
|
||||
var stacks: [Stack] { dockgeService?.stacks ?? [] }
|
||||
|
||||
// MARK: - Navigation
|
||||
var selectedTab: Tab = .stacks
|
||||
|
||||
enum Tab { case stacks, servers, settings }
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init() {
|
||||
loadServers()
|
||||
loadAppearance()
|
||||
loadLogRefreshInterval()
|
||||
}
|
||||
|
||||
// MARK: - Server management
|
||||
|
||||
func addServer(_ server: DockgeServer) {
|
||||
servers.append(server)
|
||||
}
|
||||
|
||||
func removeServer(at offsets: IndexSet) {
|
||||
for index in offsets {
|
||||
let server = servers[index]
|
||||
KeychainService.delete(forKey: KeychainService.tokenKey(for: server.id))
|
||||
if activeServer?.id == server.id { disconnectFromServer() }
|
||||
}
|
||||
servers.remove(atOffsets: offsets)
|
||||
}
|
||||
|
||||
func updateServer(_ server: DockgeServer) {
|
||||
if let index = servers.firstIndex(where: { $0.id == server.id }) {
|
||||
servers[index] = server
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection
|
||||
|
||||
func connectToServer(_ server: DockgeServer) {
|
||||
if dockgeService != nil { disconnectFromServer() }
|
||||
|
||||
activeServer = server
|
||||
let service = DockgeService(server: server)
|
||||
dockgeService = service
|
||||
service.connect()
|
||||
}
|
||||
|
||||
func disconnectFromServer() {
|
||||
dockgeService?.disconnect()
|
||||
dockgeService = nil
|
||||
activeServer = nil
|
||||
}
|
||||
|
||||
// MARK: - Persistence (servers stored in UserDefaults as JSON; tokens in Keychain)
|
||||
|
||||
// MARK: - Log refresh interval
|
||||
|
||||
private let logRefreshKey = "dockge.logRefreshInterval"
|
||||
|
||||
var logRefreshInterval: Int = 10 {
|
||||
didSet { UserDefaults.standard.set(logRefreshInterval, forKey: logRefreshKey) }
|
||||
}
|
||||
|
||||
private func loadLogRefreshInterval() {
|
||||
let stored = UserDefaults.standard.integer(forKey: logRefreshKey)
|
||||
if [10, 30, 60].contains(stored) { logRefreshInterval = stored }
|
||||
}
|
||||
|
||||
// MARK: - Appearance
|
||||
|
||||
private let appearanceKey = "dockge.appearanceMode"
|
||||
|
||||
var appearanceMode: AppearanceMode = .dark {
|
||||
didSet { UserDefaults.standard.set(appearanceMode.rawValue, forKey: appearanceKey) }
|
||||
}
|
||||
|
||||
private func loadAppearance() {
|
||||
guard let raw = UserDefaults.standard.string(forKey: appearanceKey),
|
||||
let mode = AppearanceMode(rawValue: raw) else { return }
|
||||
appearanceMode = mode
|
||||
}
|
||||
|
||||
// MARK: - Auto-connect
|
||||
|
||||
private let autoConnectKey = "dockge.autoConnectServerID"
|
||||
|
||||
var autoConnectServerID: UUID? {
|
||||
didSet {
|
||||
if let id = autoConnectServerID {
|
||||
UserDefaults.standard.set(id.uuidString, forKey: autoConnectKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: autoConnectKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setAutoConnect(for server: DockgeServer?) {
|
||||
autoConnectServerID = server?.id
|
||||
}
|
||||
|
||||
// MARK: - Persistence (servers stored in UserDefaults as JSON; tokens in Keychain)
|
||||
|
||||
private let serversKey = "dockge.servers"
|
||||
|
||||
private func persistServers() {
|
||||
guard let data = try? JSONEncoder().encode(servers) else { return }
|
||||
UserDefaults.standard.set(data, forKey: serversKey)
|
||||
}
|
||||
|
||||
private func loadServers() {
|
||||
if let str = UserDefaults.standard.string(forKey: autoConnectKey),
|
||||
let id = UUID(uuidString: str) {
|
||||
autoConnectServerID = id
|
||||
}
|
||||
guard let data = UserDefaults.standard.data(forKey: serversKey),
|
||||
let decoded = try? JSONDecoder().decode([DockgeServer].self, from: data) else { return }
|
||||
servers = decoded
|
||||
if let id = autoConnectServerID, let server = servers.first(where: { $0.id == id }) {
|
||||
connectToServer(server)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
@State private var name = ""
|
||||
@State private var host = ""
|
||||
@State private var useSSL = false
|
||||
@State private var editingServer: DockgeServer?
|
||||
|
||||
// Pass an existing server to edit
|
||||
init(server: DockgeServer? = nil) {
|
||||
if let server {
|
||||
_name = State(initialValue: server.name)
|
||||
_host = State(initialValue: server.host)
|
||||
_useSSL = State(initialValue: server.useSSL)
|
||||
_editingServer = State(initialValue: server)
|
||||
}
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
!name.trimmingCharacters(in: .whitespaces).isEmpty &&
|
||||
!host.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
TextField("Friendly name (e.g. Home Server)", text: $name)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Host (e.g. 192.168.1.10:5001)", text: $host)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
.listRowBackground(Color.appSurface)
|
||||
|
||||
Section {
|
||||
Toggle("Use HTTPS / WSS", isOn: $useSSL)
|
||||
} footer: {
|
||||
Text("Enable for TLS-secured Dockge instances. Self-signed certificates are accepted.")
|
||||
}
|
||||
.listRowBackground(Color.appSurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackground)
|
||||
.navigationTitle(editingServer == nil ? "Add Server" : "Edit Server")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") { save() }
|
||||
.disabled(!isValid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespaces)
|
||||
if var existing = editingServer {
|
||||
existing.name = name.trimmingCharacters(in: .whitespaces)
|
||||
existing.host = trimmedHost
|
||||
existing.useSSL = useSSL
|
||||
appState.updateServer(existing)
|
||||
} else {
|
||||
let server = DockgeServer(
|
||||
name: name.trimmingCharacters(in: .whitespaces),
|
||||
host: trimmedHost,
|
||||
useSSL: useSSL
|
||||
)
|
||||
appState.addServer(server)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
let service: DockgeService
|
||||
let server: DockgeServer
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var twoFAToken = ""
|
||||
@State private var showTwoFA = false
|
||||
@State private var showSetup = false
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Logo / header
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.appAccent)
|
||||
Text(server.name)
|
||||
.font(.title2.bold())
|
||||
Text(server.displayHost)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.appGray)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.appRed.opacity(0.8), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
if showSetup {
|
||||
Text("First-time Setup")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.appGray)
|
||||
}
|
||||
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.padding()
|
||||
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
.textFieldStyle(.plain)
|
||||
.padding()
|
||||
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
if showTwoFA {
|
||||
TextField("2FA Code", text: $twoFAToken)
|
||||
.textFieldStyle(.plain)
|
||||
.keyboardType(.numberPad)
|
||||
.padding()
|
||||
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await submit() }
|
||||
} label: {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text(showSetup ? "Create Account" : "Login")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.appAccent, in: RoundedRectangle(cornerRadius: 10))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.disabled(isLoading || username.isEmpty || password.isEmpty)
|
||||
|
||||
if showSetup {
|
||||
Button("Back to Login") {
|
||||
showSetup = false
|
||||
showTwoFA = false
|
||||
errorMessage = nil
|
||||
}
|
||||
.foregroundStyle(.appAccent)
|
||||
} else {
|
||||
Button("First-time Setup") {
|
||||
showSetup = true
|
||||
showTwoFA = false
|
||||
errorMessage = nil
|
||||
}
|
||||
.foregroundStyle(.appGray)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 32)
|
||||
}
|
||||
.background(Color.appBackground)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.onChange(of: service.authState) { _, newState in
|
||||
if case .authenticated = newState { dismiss() }
|
||||
if case .needsSetup = newState { showSetup = true }
|
||||
}
|
||||
}
|
||||
|
||||
private func submit() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let error: String?
|
||||
if showSetup {
|
||||
error = await service.setup(username: username, password: password)
|
||||
} else {
|
||||
error = await service.login(
|
||||
username: username,
|
||||
password: password,
|
||||
twoFAToken: showTwoFA && !twoFAToken.isEmpty ? twoFAToken : nil
|
||||
)
|
||||
}
|
||||
isLoading = false
|
||||
|
||||
if let msg = error {
|
||||
if msg == "2fa" {
|
||||
showTwoFA = true
|
||||
errorMessage = "Enter your two-factor authentication code."
|
||||
} else {
|
||||
errorMessage = msg
|
||||
}
|
||||
}
|
||||
// On success, onChange(of: service.authState) will dismiss
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ServersView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
@State private var showAddServer = false
|
||||
@State private var serverToEdit: DockgeServer?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if appState.servers.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Servers", systemImage: "server.rack")
|
||||
} description: {
|
||||
Text("Tap + to add a Dockge server.")
|
||||
} actions: {
|
||||
Button("Add Server") { showAddServer = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.appAccent)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
ForEach(appState.servers) { server in
|
||||
serverRow(server)
|
||||
.listRowBackground(Color.appSurface)
|
||||
}
|
||||
.onDelete(perform: appState.removeServer)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.background(Color.appBackground)
|
||||
.navigationTitle("Servers")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button { showAddServer = true } label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddServer) {
|
||||
AddServerView()
|
||||
}
|
||||
.sheet(item: $serverToEdit) { server in
|
||||
AddServerView(server: server)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func serverRow(_ server: DockgeServer) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
// Connection indicator
|
||||
Circle()
|
||||
.fill(appState.activeServer?.id == server.id ? Color.appGreen : Color.appGray)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(server.name)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if appState.autoConnectServerID == server.id {
|
||||
Text("Auto")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Color.appAccent)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.appAccent.opacity(0.2), in: Capsule())
|
||||
.overlay(Capsule().strokeBorder(Color.appAccent.opacity(0.4), lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Text(server.displayHost)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.appGray)
|
||||
if server.useSSL {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.appAccent)
|
||||
}
|
||||
}
|
||||
if let active = appState.activeServer, active.id == server.id,
|
||||
let info = appState.dockgeService?.serverInfo {
|
||||
Text("v\(info.version)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.appGray)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Connect / Disconnect button
|
||||
if appState.activeServer?.id == server.id {
|
||||
Button {
|
||||
appState.disconnectFromServer()
|
||||
} label: {
|
||||
Text("Disconnect")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.appRed)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.appRed.opacity(0.15), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
appState.connectToServer(server)
|
||||
} label: {
|
||||
Text("Connect")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.appAccent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.appAccent.opacity(0.15), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
if let index = appState.servers.firstIndex(where: { $0.id == server.id }) {
|
||||
appState.removeServer(at: IndexSet([index]))
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
|
||||
Button {
|
||||
serverToEdit = server
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.appAccent)
|
||||
|
||||
if appState.activeServer?.id == server.id {
|
||||
Button {
|
||||
appState.dockgeService?.logout()
|
||||
} label: {
|
||||
Label("Logout", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
.tint(.appRed)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if appState.autoConnectServerID == server.id {
|
||||
Button {
|
||||
appState.setAutoConnect(for: nil)
|
||||
} label: {
|
||||
Label("Remove Auto", systemImage: "bolt.slash")
|
||||
}
|
||||
.tint(.appGray)
|
||||
} else {
|
||||
Button {
|
||||
appState.setAutoConnect(for: server)
|
||||
} label: {
|
||||
Label("Auto Connect", systemImage: "bolt")
|
||||
}
|
||||
.tint(.appAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Appearance") {
|
||||
@Bindable var state = appState
|
||||
Picker("Theme", selection: $state.appearanceMode) {
|
||||
ForEach(AppearanceMode.allCases, id: \.self) { mode in
|
||||
Text(mode.label).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.listRowBackground(Color.appSurface)
|
||||
|
||||
Section("Logs") {
|
||||
@Bindable var state = appState
|
||||
Picker("Refresh Rate", selection: $state.logRefreshInterval) {
|
||||
Text("10 s").tag(10)
|
||||
Text("30 s").tag(30)
|
||||
Text("60 s").tag(60)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.listRowBackground(Color.appSurface)
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("App", value: "dock-g")
|
||||
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
|
||||
LabeledContent("Version", value: version)
|
||||
}
|
||||
LabeledContent("Platform", value: "Dockge Mobile Client")
|
||||
}
|
||||
.listRowBackground(Color.appSurface)
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackground)
|
||||
.navigationTitle("Settings")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
|
||||
// Shows terminal output after a stack action (start/stop/restart/update/down/deploy etc.)
|
||||
struct ActionTerminalSheet: View {
|
||||
let title: String
|
||||
let terminalName: String
|
||||
let stackName: String
|
||||
let endpoint: String
|
||||
let service: DockgeService
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var displayAttr = AttributedString()
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.tint(.appAccent)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(24)
|
||||
} else if displayAttr == AttributedString() {
|
||||
Text("No output.")
|
||||
.font(.monoSmall)
|
||||
.foregroundStyle(Color.appGray)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
} else {
|
||||
Text(displayAttr)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
}
|
||||
Color.clear.frame(height: 1).id("end")
|
||||
}
|
||||
.background(Color.terminalBg)
|
||||
.onChange(of: displayAttr) { _, _ in
|
||||
withAnimation(.none) { proxy.scrollTo("end") }
|
||||
}
|
||||
}
|
||||
|
||||
Button("Done") { dismiss() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.appAccent)
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
.onAppear {
|
||||
// Replay local buffer immediately (synchronous if buffer exists)
|
||||
service.onTerminalWrite(name: terminalName) { buf in
|
||||
displayAttr = ANSIParser.attributedString(from: buf)
|
||||
}
|
||||
// Show whatever we have right away — don't block on a slow/no-op server call
|
||||
isLoading = false
|
||||
// Background fallback: ask the server for its buffer in case local events
|
||||
// were missed (fire-and-forget, updates displayAttr via the listener above)
|
||||
Task {
|
||||
await service.joinActionTerminal(stackName: stackName, endpoint: endpoint)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
service.removeTerminalListeners(name: terminalName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ComposeTabView: View {
|
||||
@Binding var yaml: String
|
||||
var isEditing: Bool
|
||||
var onSave: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if isEditing {
|
||||
TextEditor(text: $yaml)
|
||||
.font(.monoBody)
|
||||
.foregroundStyle(Color.terminalText)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 400)
|
||||
.padding(8)
|
||||
} else {
|
||||
Text(yaml.isEmpty ? " " : yaml)
|
||||
.font(.monoBody)
|
||||
.foregroundStyle(Color.terminalText)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.background(Color.terminalBg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CreateStackView: View {
|
||||
let service: DockgeService
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var stackName = ""
|
||||
@State private var selectedEndpoint = ""
|
||||
@State private var composeYAML = Self.defaultYAML
|
||||
@State private var envContent = ""
|
||||
|
||||
// Converter
|
||||
@State private var dockerRunCommand = ""
|
||||
@State private var isConverting = false
|
||||
@State private var converterExpanded = false
|
||||
|
||||
// Actions
|
||||
@State private var isWorking = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showTerminal = false
|
||||
@State private var deployTerminalName = ""
|
||||
|
||||
private static let defaultYAML = """
|
||||
services:
|
||||
app:
|
||||
image:
|
||||
restart: unless-stopped
|
||||
"""
|
||||
|
||||
private var nameIsValid: Bool {
|
||||
let trimmed = stackName.trimmingCharacters(in: .whitespaces)
|
||||
return !trimmed.isEmpty && trimmed.range(of: #"^[a-z0-9_-]+$"#, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
stackInfoSection
|
||||
converterSection
|
||||
composeSection
|
||||
envSection
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.appRed.opacity(0.8), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color.appBackground)
|
||||
.navigationTitle("New Stack")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarItems }
|
||||
}
|
||||
|
||||
.sheet(isPresented: $showTerminal) {
|
||||
ActionTerminalSheet(
|
||||
title: "Deploying \(stackName)",
|
||||
terminalName: deployTerminalName,
|
||||
stackName: stackName,
|
||||
endpoint: selectedEndpoint,
|
||||
service: service
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var stackInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("Stack")
|
||||
VStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
TextField("stack-name", text: $stackName)
|
||||
.font(.system(size: 15, design: .monospaced))
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.padding(10)
|
||||
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 8))
|
||||
if !stackName.isEmpty && !nameIsValid {
|
||||
Text("Only lowercase letters, numbers, - and _ allowed")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color.appRed)
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
}
|
||||
|
||||
if service.availableEndpoints.count > 1 {
|
||||
Picker("Agent", selection: $selectedEndpoint) {
|
||||
ForEach(service.availableEndpoints, id: \.self) { ep in
|
||||
Text(ep.isEmpty ? "Local" : ep).tag(ep)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.padding(10)
|
||||
.background(Color.appSurface, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var converterSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { converterExpanded.toggle() }
|
||||
} label: {
|
||||
HStack {
|
||||
sectionHeader("Convert from docker run")
|
||||
Spacer()
|
||||
Image(systemName: converterExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(Color.appGray)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if converterExpanded {
|
||||
VStack(spacing: 8) {
|
||||
TextEditor(text: $dockerRunCommand)
|
||||
.font(.monoSmall)
|
||||
.foregroundStyle(Color.terminalText)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 80)
|
||||
.padding(8)
|
||||
.background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
Group {
|
||||
if dockerRunCommand.isEmpty {
|
||||
Text("docker run -p 8080:80 nginx")
|
||||
.font(.monoSmall)
|
||||
.foregroundStyle(Color.appGray.opacity(0.6))
|
||||
.allowsHitTesting(false)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Button {
|
||||
Task { await runConverter() }
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
if isConverting {
|
||||
ProgressView().scaleEffect(0.7)
|
||||
} else {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
Text(isConverting ? "Converting…" : "Convert")
|
||||
}
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.appAccent, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.disabled(dockerRunCommand.trimmingCharacters(in: .whitespaces).isEmpty || isConverting)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var composeSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader("compose.yaml")
|
||||
TextEditor(text: $composeYAML)
|
||||
.font(.monoBody)
|
||||
.foregroundStyle(Color.terminalText)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 200)
|
||||
.padding(8)
|
||||
.background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private var envSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
sectionHeader(".env (optional)")
|
||||
TextEditor(text: $envContent)
|
||||
.font(.monoBody)
|
||||
.foregroundStyle(Color.terminalText)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 80)
|
||||
.padding(8)
|
||||
.background(Color.terminalBg, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Color.appGray)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarItems: some ToolbarContent {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button {
|
||||
Task { await deploy() }
|
||||
} label: {
|
||||
Label("Save & Deploy", systemImage: "bolt.fill")
|
||||
}
|
||||
Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
Label("Save Draft", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
} label: {
|
||||
if isWorking {
|
||||
ProgressView().scaleEffect(0.8)
|
||||
} else {
|
||||
Text("Create").fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.disabled(!nameIsValid || isWorking)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func runConverter() async {
|
||||
let cmd = dockerRunCommand.trimmingCharacters(in: .whitespaces)
|
||||
guard !cmd.isEmpty else { return }
|
||||
isConverting = true
|
||||
errorMessage = nil
|
||||
if let result = await service.composerize(cmd) {
|
||||
composeYAML = result
|
||||
withAnimation { converterExpanded = false }
|
||||
} else {
|
||||
errorMessage = "Conversion failed. Check your docker run command."
|
||||
}
|
||||
isConverting = false
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
guard nameIsValid else { return }
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
let error = await service.saveStack(
|
||||
name: stackName.trimmingCharacters(in: .whitespaces),
|
||||
yaml: composeYAML,
|
||||
env: envContent,
|
||||
isAdd: true,
|
||||
endpoint: selectedEndpoint
|
||||
)
|
||||
isWorking = false
|
||||
if let msg = error {
|
||||
errorMessage = msg
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private func deploy() async {
|
||||
guard nameIsValid else { return }
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
let name = stackName.trimmingCharacters(in: .whitespaces)
|
||||
deployTerminalName = service.terminalName(for: name, endpoint: selectedEndpoint)
|
||||
service.clearTerminalBuffer(name: deployTerminalName)
|
||||
let error = await service.deployStack(
|
||||
name: name,
|
||||
yaml: composeYAML,
|
||||
env: envContent,
|
||||
isAdd: true,
|
||||
endpoint: selectedEndpoint
|
||||
)
|
||||
isWorking = false
|
||||
if let msg = error {
|
||||
errorMessage = msg
|
||||
} else {
|
||||
showTerminal = true
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EnvTabView: View {
|
||||
@Binding var env: String
|
||||
var isEditing: Bool
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
if isEditing {
|
||||
TextEditor(text: $env)
|
||||
.font(.monoBody)
|
||||
.foregroundStyle(Color.terminalText)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 400)
|
||||
.padding(8)
|
||||
} else {
|
||||
Text(env.isEmpty ? " " : env)
|
||||
.font(.monoBody)
|
||||
.foregroundStyle(Color.terminalText)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
.background(Color.terminalBg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogsTabView: View {
|
||||
let stackName: String
|
||||
let endpoint: String
|
||||
let service: DockgeService
|
||||
|
||||
@Environment(AppState.self) private var appState
|
||||
|
||||
@State private var displayAttr = AttributedString()
|
||||
@State private var hasContent = false
|
||||
@State private var autoScroll = true
|
||||
@State private var refreshTask: Task<Void, Never>?
|
||||
@State private var lastUpdated: Date?
|
||||
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "HH:mm:ss"
|
||||
return f
|
||||
}()
|
||||
|
||||
private var terminalName: String {
|
||||
service.combinedTerminalName(for: stackName, endpoint: endpoint)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if let date = lastUpdated {
|
||||
Text("Updated \(Self.timeFormatter.string(from: date))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Color.appGray)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("Auto-scroll", isOn: $autoScroll)
|
||||
.toggleStyle(.switch)
|
||||
.tint(.appAccent)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color.appGray)
|
||||
|
||||
Button {
|
||||
displayAttr = AttributedString()
|
||||
hasContent = false
|
||||
service.clearTerminalBuffer(name: terminalName)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color.appGray)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.appSurface)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
if hasContent {
|
||||
Text(displayAttr)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
} else {
|
||||
Text("Waiting for logs\u{2026}")
|
||||
.font(.monoSmall)
|
||||
.foregroundStyle(Color.appGray)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
}
|
||||
Color.clear.frame(height: 1).id("logEnd")
|
||||
}
|
||||
.background(Color.terminalBg)
|
||||
.onChange(of: displayAttr) { _, _ in
|
||||
if autoScroll {
|
||||
withAnimation(.none) {
|
||||
proxy.scrollTo("logEnd")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Each joinTerminal call delivers the full buffer; re-parse the whole thing.
|
||||
service.onTerminalWrite(name: terminalName) { fullBuffer in
|
||||
displayAttr = ANSIParser.attributedString(from: fullBuffer)
|
||||
hasContent = !fullBuffer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
// Initial load + 10-second polling loop
|
||||
refreshTask = Task {
|
||||
while !Task.isCancelled {
|
||||
await service.joinTerminal(stackName: stackName, endpoint: endpoint)
|
||||
lastUpdated = Date()
|
||||
try? await Task.sleep(for: .seconds(appState.logRefreshInterval))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
service.removeTerminalListeners(name: terminalName)
|
||||
service.leaveTerminal(stackName: stackName, endpoint: endpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ServicesTabView: View {
|
||||
let stackName: String
|
||||
let endpoint: String
|
||||
let service: DockgeService
|
||||
|
||||
@State private var services: [ServiceStatus] = []
|
||||
@State private var isLoading = true
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if services.isEmpty {
|
||||
ContentUnavailableView("No Services", systemImage: "cube")
|
||||
} else {
|
||||
List(services) { svc in
|
||||
ServiceRowView(service: svc)
|
||||
.listRowBackground(Color.appSurface)
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
|
||||
private func reload() async {
|
||||
isLoading = true
|
||||
services = await service.serviceStatusList(stackName: stackName, endpoint: endpoint)
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private struct ServiceRowView: View {
|
||||
let service: ServiceStatus
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(service.stateColor)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(service.name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Text(service.state)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(service.stateColor)
|
||||
}
|
||||
if !service.ports.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(service.ports, id: \.self) { port in
|
||||
Text(port)
|
||||
.font(.monoSmall)
|
||||
.foregroundStyle(.appGray)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StackDetailView: View {
|
||||
let initialStack: Stack
|
||||
let service: DockgeService
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var stack: Stack
|
||||
@State private var selectedTab = 0
|
||||
@State private var isEditing = false
|
||||
@State private var editYAML = ""
|
||||
@State private var editENV = ""
|
||||
@State private var isLoading = true
|
||||
@State private var actionInProgress: String?
|
||||
@State private var actionError: String?
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
init(initialStack: Stack, service: DockgeService) {
|
||||
self.initialStack = initialStack
|
||||
self.service = service
|
||||
_stack = State(initialValue: initialStack)
|
||||
}
|
||||
|
||||
// MARK: - Custom floppy disk icon (not in SF Symbols)
|
||||
|
||||
private struct FloppyDiskShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let w = rect.width, h = rect.height
|
||||
let cr = min(w, h) * 0.12 // corner radius (3 rounded corners)
|
||||
let cut = min(w, h) * 0.20 // top-right diagonal cut
|
||||
|
||||
var p = Path()
|
||||
|
||||
// Outer body: rounded rect with clipped top-right corner
|
||||
p.move(to: CGPoint(x: cr, y: 0))
|
||||
p.addLine(to: CGPoint(x: w - cut, y: 0))
|
||||
p.addLine(to: CGPoint(x: w, y: cut))
|
||||
p.addLine(to: CGPoint(x: w, y: h - cr))
|
||||
p.addArc(center: CGPoint(x: w - cr, y: h - cr), radius: cr,
|
||||
startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
|
||||
p.addLine(to: CGPoint(x: cr, y: h))
|
||||
p.addArc(center: CGPoint(x: cr, y: h - cr), radius: cr,
|
||||
startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
|
||||
p.addLine(to: CGPoint(x: 0, y: cr))
|
||||
p.addArc(center: CGPoint(x: cr, y: cr), radius: cr,
|
||||
startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
||||
p.closeSubpath()
|
||||
|
||||
// Shutter slot — cutout at the top (even-odd → hole)
|
||||
let slotX = w * 0.15
|
||||
let slotW = w - slotX - cut * 0.5
|
||||
p.addRect(CGRect(x: slotX, y: 0, width: slotW, height: h * 0.42))
|
||||
|
||||
// Hub inside shutter — small rectangle (3rd level → solid again)
|
||||
let hubW = w * 0.22, hubH = h * 0.20
|
||||
p.addRect(CGRect(x: (w - hubW) * 0.5, y: h * 0.06, width: hubW, height: hubH))
|
||||
|
||||
// Label area at bottom — cutout (even-odd → hole)
|
||||
let labInset = w * 0.10
|
||||
p.addRect(CGRect(x: labInset, y: h * 0.54, width: w - labInset * 2, height: h * 0.34))
|
||||
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
headerSection
|
||||
|
||||
// Error banner
|
||||
if let error = actionError {
|
||||
Text(error)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.appRed.opacity(0.8))
|
||||
}
|
||||
|
||||
// Tabs
|
||||
Picker("Tab", selection: $selectedTab) {
|
||||
Text("Services").tag(0)
|
||||
Text("Compose").tag(1)
|
||||
Text("Env").tag(2)
|
||||
Text("Logs").tag(3)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.appSurface)
|
||||
|
||||
// Tab content
|
||||
tabContent
|
||||
}
|
||||
.background(Color.appBackground)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(stack.status.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(stack.name)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
if selectedTab == 1 || selectedTab == 2 {
|
||||
if isEditing {
|
||||
Button {
|
||||
Task { await deployChanges() }
|
||||
} label: {
|
||||
Image(systemName: "hammer.fill")
|
||||
}
|
||||
.disabled(actionInProgress != nil)
|
||||
Button {
|
||||
Task { await saveChanges() }
|
||||
} label: {
|
||||
FloppyDiskShape()
|
||||
.fill(style: FillStyle(eoFill: true))
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
.disabled(actionInProgress != nil)
|
||||
Button {
|
||||
editYAML = stack.composeYAML ?? ""
|
||||
editENV = stack.composeENV ?? ""
|
||||
isEditing = false
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
} else {
|
||||
Button { isEditing = true } label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.confirmationDialog(
|
||||
"Delete stack?",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete Stack", role: .destructive) {
|
||||
Task {
|
||||
let error = await service.deleteStack(stack.name, endpoint: stack.endpoint)
|
||||
if error == nil { dismiss() }
|
||||
if let msg = error { actionError = msg }
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This will run docker compose down and remove all stack files. This cannot be undone.")
|
||||
}
|
||||
.task { await loadDetails() }
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(stack.status.label)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(stack.status.color)
|
||||
if !stack.primaryHostname.isEmpty {
|
||||
Text(stack.primaryHostname)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color.appGray)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if let action = actionInProgress {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().tint(.appAccent).scaleEffect(0.8)
|
||||
Text(action + "…")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color.appGray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
if stack.status != .running {
|
||||
actionButton("Start", icon: "play.fill", color: .appGreen) {
|
||||
await performAction("startStack", display: "Starting")
|
||||
}
|
||||
}
|
||||
if stack.status == .running || stack.status == .unknown || stack.status == .exited {
|
||||
actionButton("Stop", icon: "stop.fill", color: .appRed) {
|
||||
await performAction("stopStack", display: "Stopping")
|
||||
}
|
||||
actionButton("Restart", icon: "arrow.counterclockwise", color: .appAccent) {
|
||||
await performAction("restartStack", display: "Restarting")
|
||||
}
|
||||
actionButton("Update", icon: "arrow.down.circle", color: .appAccent) {
|
||||
await performAction("updateStack", display: "Updating")
|
||||
}
|
||||
}
|
||||
actionButton("Down", icon: "arrow.down.to.line", color: .appGray) {
|
||||
await performAction("downStack", display: "Taking down")
|
||||
}
|
||||
actionButton("Delete", icon: "trash", color: .appRed) {
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appSurface)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func actionButton(_ label: String, icon: String, color: Color, action: @escaping () async -> Void) -> some View {
|
||||
Button {
|
||||
Task { await action() }
|
||||
} label: {
|
||||
Label(label, systemImage: icon)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.disabled(actionInProgress != nil)
|
||||
}
|
||||
|
||||
// MARK: - Tab content
|
||||
|
||||
@ViewBuilder
|
||||
private var tabContent: some View {
|
||||
switch selectedTab {
|
||||
case 0:
|
||||
ServicesTabView(stackName: stack.name, endpoint: stack.endpoint, service: service)
|
||||
case 1:
|
||||
ComposeTabView(yaml: $editYAML, isEditing: isEditing, onSave: saveChanges)
|
||||
case 2:
|
||||
EnvTabView(env: $editENV, isEditing: isEditing)
|
||||
case 3:
|
||||
LogsTabView(stackName: stack.name, endpoint: stack.endpoint, service: service)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func loadDetails() async {
|
||||
isLoading = true
|
||||
if let detailed = await service.getStack(name: stack.name, endpoint: stack.endpoint) {
|
||||
// getStack often returns status=0 (unknown) because the agent hasn't
|
||||
// polled docker compose ps yet. The stackList already has the real status,
|
||||
// so keep it unless getStack gives us something more specific.
|
||||
let resolvedStatus = detailed.status == .unknown ? stack.status : detailed.status
|
||||
stack = Stack(
|
||||
name: detailed.name,
|
||||
status: resolvedStatus,
|
||||
isManagedByDockge: detailed.isManagedByDockge,
|
||||
composeFileName: detailed.composeFileName,
|
||||
endpoint: detailed.endpoint,
|
||||
primaryHostname: detailed.primaryHostname,
|
||||
composeYAML: detailed.composeYAML,
|
||||
composeENV: detailed.composeENV,
|
||||
tags: detailed.tags
|
||||
)
|
||||
editYAML = detailed.composeYAML ?? ""
|
||||
editENV = detailed.composeENV ?? ""
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func performAction(_ event: String, display: String) async {
|
||||
actionInProgress = display
|
||||
actionError = nil
|
||||
|
||||
let error: String?
|
||||
switch event {
|
||||
case "startStack": error = await service.startStack(stack.name, endpoint: stack.endpoint)
|
||||
case "stopStack": error = await service.stopStack(stack.name, endpoint: stack.endpoint)
|
||||
case "restartStack": error = await service.restartStack(stack.name, endpoint: stack.endpoint)
|
||||
case "updateStack": error = await service.updateStack(stack.name, endpoint: stack.endpoint)
|
||||
case "downStack": error = await service.downStack(stack.name, endpoint: stack.endpoint)
|
||||
default: error = "Unknown action"
|
||||
}
|
||||
|
||||
actionInProgress = nil
|
||||
if let msg = error { actionError = msg }
|
||||
await loadDetails()
|
||||
}
|
||||
|
||||
private func saveChanges() async {
|
||||
actionInProgress = "Saving"
|
||||
actionError = nil
|
||||
let error = await service.saveStack(
|
||||
name: stack.name,
|
||||
yaml: editYAML,
|
||||
env: editENV,
|
||||
isAdd: false,
|
||||
endpoint: stack.endpoint
|
||||
)
|
||||
actionInProgress = nil
|
||||
if let msg = error {
|
||||
actionError = msg
|
||||
} else {
|
||||
isEditing = false
|
||||
await loadDetails()
|
||||
}
|
||||
}
|
||||
|
||||
private func deployChanges() async {
|
||||
actionInProgress = "Deploying"
|
||||
actionError = nil
|
||||
|
||||
let error = await service.deployStack(
|
||||
name: stack.name,
|
||||
yaml: editYAML,
|
||||
env: editENV,
|
||||
isAdd: false,
|
||||
endpoint: stack.endpoint
|
||||
)
|
||||
actionInProgress = nil
|
||||
|
||||
if let msg = error {
|
||||
actionError = msg
|
||||
} else {
|
||||
isEditing = false
|
||||
await loadDetails()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StackRowView: View, Equatable {
|
||||
let stack: Stack
|
||||
|
||||
private var machineLabel: String {
|
||||
if stack.endpoint.isEmpty {
|
||||
return "local"
|
||||
}
|
||||
// endpoint is "host:port" — show only the host
|
||||
return stack.endpoint.components(separatedBy: ":").first ?? stack.endpoint
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(stack.status.color)
|
||||
.frame(width: 10, height: 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(stack.name)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(stack.status.label)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.appGray)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !stack.tags.isEmpty {
|
||||
ForEach(stack.tags.prefix(2), id: \.self) { tag in
|
||||
Text(tag)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Color.appAccent)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.appAccent.opacity(0.2), in: Capsule())
|
||||
.overlay(Capsule().strokeBorder(Color.appAccent.opacity(0.4), lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
|
||||
Text(machineLabel)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Color.appGray)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.appGray.opacity(0.15), in: Capsule())
|
||||
.overlay(Capsule().strokeBorder(Color.appGray.opacity(0.35), lineWidth: 0.5))
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.appGray)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |