Files
proxy-studio/proxy studio/ContentView.swift
2026-09-27 15:42:22 +02:00

368 lines
15 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}