import SwiftUI enum Destination: String, CaseIterable, Identifiable { case overview = "Overview" case hosts = "Proxy Hosts" case certificates = "Certificates" case activity = "Activity" case dns = "UniFi DNS" var id: String { rawValue } var icon: String { switch self { case .overview: "server.rack" case .hosts: "network" case .certificates: "checkmark.shield" case .activity: "clock" case .dns: "globe" } } } struct ContentView: View { @State private var store = ProxyStore() @State private var unifi = UniFiStore() @State private var destination: Destination? = .hosts @State private var search = "" @State private var dnsSearch = "" @State private var filter = "All Hosts" @State private var selectedID: Int? @State private var sortOrder = [KeyPathComparator(\ProxyHost.domain)] @State private var editor: ProxyHost? @State private var creating = false @State private var showConnection = false @State private var showInspector = true @State private var deleting: ProxyHost? private var selectedHost: ProxyHost? { store.hosts.first { $0.id == selectedID } } private var filteredHosts: [ProxyHost] { store.hosts.filter { (search.isEmpty || $0.domain.localizedCaseInsensitiveContains(search) || $0.destination.contains(search)) && (filter == "All Hosts" || (filter == "Enabled" && $0.enabled) || (filter == "Disabled" && !$0.enabled)) }.sorted(using: sortOrder) } var body: some View { NavigationSplitView { List(selection: $destination) { Section(store.isDemo ? "Demo Server" : store.serverName) { ForEach([Destination.overview, .hosts, .certificates, .activity]) { item in Label(item.rawValue, systemImage: item.icon) .badge(item == .hosts ? store.hosts.count : 0) .tag(item) } } Section("UniFi Network") { Label("DNS Records", systemImage: "globe").tag(Destination.dns) } } .listStyle(.sidebar) .navigationSplitViewColumnWidth(min: 180, ideal: 210, max: 280) .safeAreaInset(edge: .bottom) { Button { showConnection = true } label: { Label("Connect to Server…", systemImage: "server.rack") .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(.borderless) .padding() } } detail: { // The minimum size belongs to the detail column, not the split view. A fixed // window minimum smaller than sidebar + detail + inspector minimums makes the // split view re-solve its constraints forever and AppKit aborts. detail .frame(minWidth: 520, minHeight: 550) .navigationTitle(destination?.rawValue ?? "Proxy Studio") .navigationSubtitle(destination == .dns ? unifi.connectionLabel : (store.isDemo ? "Demo Server" : store.serverName)) .toolbar { ToolbarItemGroup { if destination != .dns { Button { Task { await store.refresh() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } .help("Refresh") .keyboardShortcut("r") .disabled(store.isBusy) } if destination == .hosts { Button(action: newHost) { Label("Add Proxy Host", systemImage: "plus") } .help("Add Proxy Host") .keyboardShortcut("n") Button { if let host = selectedHost { edit(host) } } label: { Label("Edit Host", systemImage: "square.and.pencil") } .help("Edit Selected Host") .disabled(selectedHost == nil) } } ToolbarItem { Button { showInspector.toggle() } label: { Label("Inspector", systemImage: "sidebar.right") } .help("Show or Hide Inspector") .keyboardShortcut("i", modifiers: [.command, .option]) .disabled(destination != .hosts) } } .inspector(isPresented: Binding( get: { showInspector && destination == .hosts }, set: { showInspector = $0 } )) { if let host = selectedHost { HostInspector(host: host, edit: { edit(host) }, toggle: { Task { await store.toggle(host) } }, delete: { deleting = host }) .inspectorColumnWidth(min: 260, ideal: 290, max: 380) } else { ContentUnavailableView("No Selection", systemImage: "network", description: Text("Select a proxy host to view its details.")) .inspectorColumnWidth(min: 260, ideal: 290, max: 380) } } } // A window must own only one search toolbar item. Keeping this outside the // changing detail views avoids duplicate AppKit search identifiers. .searchable( text: Binding( get: { destination == .dns ? dnsSearch : search }, set: { if destination == .dns { dnsSearch = $0 } else { search = $0 } } ), prompt: destination == .dns ? "Search DNS Records" : "Search Hosts" ) .task { guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return } await store.restoreConnection() } .task { guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" else { return } await unifi.restoreConnection() } .sheet(item: $editor) { host in HostEditor(store: store, host: host, isNew: creating) } .sheet(isPresented: $showConnection) { ConnectionSheet(store: store) } .alert("Unable to Complete Request", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) { Button("OK") { store.error = nil } } message: { Text(store.error ?? "") } .confirmationDialog("Delete \(deleting?.domain ?? "host")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) { Button("Delete Proxy Host", role: .destructive) { if let host = deleting { Task { await store.delete(host) } } deleting = nil } } message: { Text("This removes the proxy configuration. Your upstream service is not affected.") } } @ViewBuilder private var detail: some View { switch destination ?? .hosts { case .hosts: hostTable case .overview: ServerOverview(store: store, connect: { showConnection = true }) case .certificates: CertificateTable(certificates: store.certificates, isDemo: store.isDemo) case .activity: ActivityTable(entries: (store.activity + unifi.activity).sorted { $0.date > $1.date }) case .dns: UniFiDNSView(store: unifi, search: $dnsSearch) } } private var hostTable: some View { VStack(spacing: 0) { HStack { Picker("Show", selection: $filter) { Text("All Hosts").tag("All Hosts") Text("Enabled").tag("Enabled") Text("Disabled").tag("Disabled") } .frame(width: 175) Spacer() if store.isBusy { ProgressView().controlSize(.small) } } .padding(.horizontal, 12) .padding(.vertical, 8) Divider() Table(filteredHosts, selection: $selectedID, sortOrder: $sortOrder) { TableColumn("Domain", value: \.domain) { host in Label(host.domain, systemImage: "globe") } .width(min: 140, ideal: 200) TableColumn("Destination", value: \.destination) .width(min: 130, ideal: 180) TableColumn("SSL") { host in Text(host.secured ? "HTTPS" : "HTTP") } .width(65) TableColumn("Status") { host in Label(host.enabled ? "Enabled" : "Disabled", systemImage: host.enabled ? "checkmark.circle.fill" : "minus.circle") .foregroundStyle(host.enabled ? Color.primary : Color.secondary) } .width(95) } .contextMenu(forSelectionType: Int.self) { ids in if let id = ids.first, let host = store.hosts.first(where: { $0.id == id }) { Button("Edit Host…") { edit(host) } Button(host.enabled ? "Disable Host" : "Enable Host") { Task { await store.toggle(host) } } Divider() Button("Delete Host…", role: .destructive) { deleting = host } } } primaryAction: { ids in if let id = ids.first, let host = store.hosts.first(where: { $0.id == id }) { edit(host) } } .overlay { if filteredHosts.isEmpty { ContentUnavailableView("No Hosts", systemImage: "network", description: Text(search.isEmpty ? "Add a proxy host or change the status filter." : "No hosts match your search.")) } } Divider() HStack { Text("\(filteredHosts.count) hosts") Spacer() if store.isDemo { Text("Demo data — stored on this Mac") } else if let date = store.lastSynced { Text("Updated \(date.formatted(date: .omitted, time: .shortened))") } } .font(.callout) .foregroundStyle(.secondary) .padding(.horizontal, 12) .padding(.vertical, 7) } } private func edit(_ host: ProxyHost) { creating = false editor = host } private func newHost() { creating = true editor = ProxyHost.sample(0, "", "", 80, secure: false) } } struct HostInspector: View { let host: ProxyHost let edit: () -> Void let toggle: () -> Void let delete: () -> Void var body: some View { Form { Section { LabeledContent("Domain", value: host.domain) LabeledContent("Status", value: host.enabled ? "Enabled" : "Disabled") LabeledContent("ID", value: String(host.id)) } header: { Text("Proxy Host") } Section("Forwarding") { LabeledContent("Host", value: host.forwardHost) LabeledContent("Port", value: String(host.forwardPort)) LabeledContent("Scheme", value: host.forwardScheme.uppercased()) } Section("Security") { LabeledContent("Certificate", value: host.secured ? "#\(host.certificateId)" : "None") LabeledContent("Force HTTPS", value: host.sslForced ? "On" : "Off") LabeledContent("Block Exploits", value: host.blockExploits ? "On" : "Off") LabeledContent("Access", value: host.accessListId == 0 ? "Public" : "Restricted") } Section("Options") { LabeledContent("HTTP/2", value: host.http2Support ? "On" : "Off") LabeledContent("WebSockets", value: host.allowWebsocketUpgrade ? "On" : "Off") } Section { Button("Edit Host…", action: edit) Button(host.enabled ? "Disable Host" : "Enable Host", action: toggle) Button("Delete Host…", role: .destructive, action: delete) } } .formStyle(.grouped) .textSelection(.enabled) } } struct ServerOverview: View { let store: ProxyStore let connect: () -> Void var body: some View { Form { Section("Server") { LabeledContent("Name", value: store.serverName) LabeledContent("Connection", value: store.isDemo ? "Demo" : "Connected") if !store.isDemo { LabeledContent("URL", value: store.baseURL) } Button("Connect to Server…", action: connect) } Section("Configuration") { LabeledContent("Proxy Hosts", value: String(store.hosts.count)) LabeledContent("Enabled Hosts", value: String(store.hosts.filter(\.enabled).count)) LabeledContent("Hosts with SSL", value: String(store.hosts.filter(\.secured).count)) LabeledContent("Certificates", value: String(store.certificates.count)) } if store.isDemo { Section { Text("This workspace contains sample hosts. Changes are stored locally. Connect to a Nginx Proxy Manager server to manage its configuration.") .foregroundStyle(.secondary) } } } .formStyle(.grouped) } } struct CertificateTable: View { let certificates: [ProxyCertificate] let isDemo: Bool var body: some View { Table(certificates) { TableColumn("Name", value: \.niceName) TableColumn("Domains") { certificate in Text(certificate.domainNames.joined(separator: ", ")) } TableColumn("Provider") { certificate in Text(certificate.provider == "letsencrypt" ? "Let’s Encrypt" : "Custom") } TableColumn("Expiration") { certificate in Text(certificate.expiresOn ?? (isDemo ? "Demo certificate" : "Not provided")) } } .overlay { if certificates.isEmpty { ContentUnavailableView("No Certificates", systemImage: "checkmark.shield", description: Text("Certificates from your server appear here.")) } } } } struct ActivityTable: View { let entries: [ActivityEntry] var body: some View { Table(entries) { TableColumn("Time") { entry in Text(entry.date, style: .time) } .width(100) TableColumn("Action", value: \.title) TableColumn("Host", value: \.detail) } .overlay { if entries.isEmpty { ContentUnavailableView("No Activity", systemImage: "clock", description: Text("Changes made during this session appear here.")) } } } } #Preview { ContentView() .frame(width: 1180, height: 720) }