368 lines
15 KiB
Swift
368 lines
15 KiB
Swift
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: {
|
||
detail
|
||
.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"
|
||
)
|
||
.frame(minWidth: 900, minHeight: 550)
|
||
.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)
|
||
}
|