Initial Commit

This commit is contained in:
2026-09-27 15:42:22 +02:00
commit c34956249d
36 changed files with 1660 additions and 0 deletions
+367
View File
@@ -0,0 +1,367 @@
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)
}