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

249 lines
12 KiB
Swift

import SwiftUI
struct UniFiDNSView: View {
let store: UniFiStore
@Binding var search: String
@State private var selectedID: String?
@State private var sortOrder = [KeyPathComparator(\DNSRecord.domainName)]
@State private var showConnection = false
@State private var editor: DNSRecord?
@State private var creating = false
@State private var deleting: DNSRecord?
private var selectedRecord: DNSRecord? { store.records.first { $0.id == selectedID } }
private var filteredRecords: [DNSRecord] {
store.records.filter {
search.isEmpty || $0.domainName.localizedCaseInsensitiveContains(search)
|| $0.value.localizedCaseInsensitiveContains(search)
}.sorted(using: sortOrder)
}
var body: some View {
VStack(spacing: 0) {
HStack {
Picker("Site", selection: Binding(get: { store.selectedSiteID }, set: { id in
selectedID = nil
Task { await store.loadSite(id) }
})) {
ForEach(store.sites) { site in Text(site.name).tag(site.id) }
}
.frame(width: 230)
.disabled(store.isBusy)
Spacer()
if store.isBusy { ProgressView().controlSize(.small) }
Button("Connection…") { showConnection = true }.disabled(store.isBusy)
}
.padding(.horizontal, 12).padding(.vertical, 8)
Divider()
Table(filteredRecords, selection: $selectedID, sortOrder: $sortOrder) {
TableColumn("Domain", value: \.domainName)
.width(min: 160, ideal: 220)
TableColumn("Type", value: \.recordType).width(70)
TableColumn("Value", value: \.value).width(min: 150, ideal: 200)
TableColumn("TTL") { record in
Text(record.ttlSeconds.map { "\($0) s" } ?? "—")
}.width(85)
TableColumn("Status") { record in
Label(record.enabled ? "Enabled" : "Disabled", systemImage: record.enabled ? "checkmark.circle" : "minus.circle")
}.width(100)
}
.contextMenu(forSelectionType: String.self) { ids in
if let id = ids.first, let record = store.records.first(where: { $0.id == id }) {
Button("Edit A Record…") { edit(record) }.disabled(record.type != "A_RECORD" || store.isBusy)
Divider()
Button("Delete Record…", role: .destructive) { deleting = record }.disabled(store.isBusy)
}
} primaryAction: { ids in
if let id = ids.first, let record = store.records.first(where: { $0.id == id }), record.type == "A_RECORD", !store.isBusy { edit(record) }
}
.overlay {
if filteredRecords.isEmpty {
ContentUnavailableView("No DNS Records", systemImage: "globe", description: Text(search.isEmpty ? "Add an A record for the selected site." : "No records match your search."))
}
}
Divider()
HStack {
Text("\(filteredRecords.count) records")
Spacer()
Text(store.isDemo ? "Demo DNS — stored on this Mac" : store.connectionLabel)
}
.font(.callout).foregroundStyle(.secondary)
.padding(.horizontal, 12).padding(.vertical, 7)
}
.toolbar {
ToolbarItemGroup {
Button { Task { await store.loadSite(store.selectedSiteID) } } label: {
Label("Refresh DNS", systemImage: "arrow.clockwise")
}.help("Refresh DNS Records").keyboardShortcut("r").disabled(store.isBusy)
Button {
creating = true
editor = DNSRecord(id: UUID().uuidString, type: "A_RECORD", enabled: true, domain: "", ipv4Address: "", ttlSeconds: 14400)
} label: { Label("Add DNS Record", systemImage: "plus") }
.help("Add DNS Record").keyboardShortcut("n").disabled(store.isBusy)
Button { if let record = selectedRecord { edit(record) } } label: {
Label("Edit DNS Record", systemImage: "square.and.pencil")
}.help("Edit A Record").disabled(selectedRecord?.type != "A_RECORD" || store.isBusy)
}
}
.sheet(isPresented: $showConnection) { UniFiConnectionSheet(store: store) }
.sheet(item: $editor) { record in DNSRecordEditor(store: store, record: record, isNew: creating) }
.alert("UniFi Request Failed", isPresented: Binding(get: { store.error != nil }, set: { if !$0 { store.error = nil } })) {
Button("OK") { store.error = nil }
} message: { Text(store.error ?? "") }
.confirmationDialog("Delete \(deleting?.domainName ?? "record")?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), titleVisibility: .visible) {
Button("Delete DNS Record", role: .destructive) {
if let record = deleting { Task { await store.delete(record) } }
deleting = nil
}
} message: {
Text("Devices using this gateway for DNS may no longer resolve this domain.")
}
}
private func edit(_ record: DNSRecord) {
creating = false
editor = record
}
}
struct UniFiConnectionSheet: View {
let store: UniFiStore
@Environment(\.dismiss) private var dismiss
@State private var url = ""
@State private var apiKey = ""
@State private var allowUntrusted = false
@State private var error: String?
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Connect to UniFi Gateway").font(.headline)
Text("Use a local API key from UniFi Network → Settings → Control Plane → Integrations.")
.foregroundStyle(.secondary)
Form {
TextField("Gateway URL:", text: $url, prompt: Text("https://192.168.1.1"))
SecureField("API Key:", text: $apiKey)
Toggle("Allow Untrusted Gateway Certificate", isOn: $allowUntrusted)
}
.textFieldStyle(.roundedBorder)
.disabled(store.isBusy)
Text(allowUntrusted
? "Certificate verification will be disabled for this gateway connection only. Use this only on a network you trust."
: "TLS certificates are verified. Enable the option above only if your gateway uses a self-signed certificate.")
.font(.callout).foregroundStyle(.secondary)
Text("Your API key and certificate trust choice are saved in Keychain for this gateway and used to reconnect when you open the app.")
.font(.callout).foregroundStyle(.secondary)
Button("Forget Saved API Key") {
do {
try store.forgetCredentials()
apiKey = ""
error = nil
} catch { self.error = error.localizedDescription }
}
.disabled(store.isBusy)
Text("Forgetting the saved key leaves the current session connected.")
.font(.caption).foregroundStyle(.secondary)
if let error { Text(error).foregroundStyle(.red) }
HStack {
if store.isBusy { ProgressView().controlSize(.small) }
Spacer()
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction).disabled(store.isBusy)
Button("Connect") {
Task {
error = nil
do {
try await store.connect(url: url, key: apiKey, allowUntrusted: allowUntrusted)
apiKey = ""
dismiss()
} catch { self.error = error.localizedDescription }
}
}
.keyboardShortcut(.defaultAction)
.disabled(store.isBusy || url.isEmpty || apiKey.isEmpty)
}
}
.padding(24).frame(width: 500)
.onAppear { url = store.gatewayURL }
.interactiveDismissDisabled(store.isBusy)
}
}
struct DNSRecordEditor: View {
let store: UniFiStore
let isNew: Bool
@Environment(\.dismiss) private var dismiss
@State private var record: DNSRecord
@State private var domain: String
@State private var address: String
@State private var ttl: String
@State private var error: String?
init(store: UniFiStore, record: DNSRecord, isNew: Bool) {
self.store = store
self.isNew = isNew
_record = State(initialValue: record)
_domain = State(initialValue: record.domain ?? "")
_address = State(initialValue: record.ipv4Address ?? "")
_ttl = State(initialValue: String(record.ttlSeconds ?? 14400))
}
private var valid: Bool {
DNSRecord.isValidDomain(domain.trimmingCharacters(in: .whitespacesAndNewlines))
&& DNSRecord.isValidIPv4(address.trimmingCharacters(in: .whitespacesAndNewlines))
&& Int(ttl).map { (0...86400).contains($0) } == true
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text(isNew ? "New DNS Record" : "Edit DNS Record").font(.headline)
Spacer()
}.padding(20)
Form {
Section("A Record") {
LabeledContent("Site", value: store.sites.first { $0.id == store.selectedSiteID }?.name ?? "")
TextField("Domain", text: $domain, prompt: Text("app.example.com"))
TextField("IPv4 Address", text: $address, prompt: Text("192.168.1.20"))
TextField("TTL (seconds)", text: $ttl)
Toggle("Enabled", isOn: $record.enabled)
}
Section {
Text("TTL must be between 0 and 86400 seconds. For a proxied service, use the IP address of your reverse proxy.")
.font(.callout).foregroundStyle(.secondary)
}
}
.formStyle(.grouped).disabled(store.isBusy)
if let error { Text(error).foregroundStyle(.red).padding(.horizontal, 20).padding(.bottom, 12) }
Divider()
HStack {
if store.isBusy { ProgressView().controlSize(.small) }
if store.isDemo { Text("Demo gateway").font(.callout).foregroundStyle(.secondary) }
Spacer()
Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction).disabled(store.isBusy)
Button(isNew ? "Add" : "Save") {
Task {
error = nil
record.domain = domain.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
record.ipv4Address = address.trimmingCharacters(in: .whitespacesAndNewlines)
record.ttlSeconds = Int(ttl)
do {
try await store.save(record, isNew: isNew)
dismiss()
} catch { self.error = error.localizedDescription }
}
}.keyboardShortcut(.defaultAction).disabled(!valid || store.isBusy)
}.padding(20)
}
.frame(width: 500, height: 440)
.interactiveDismissDisabled(store.isBusy)
}
}
#Preview("UniFi DNS") {
NavigationStack {
UniFiDNSView(store: UniFiStore(), search: .constant(""))
.navigationTitle("UniFi DNS")
}
.frame(width: 980, height: 650)
}