Files
proxy-studio/proxy studio/UniFiDNSView.swift
T
2026-09-28 23:38:29 +02:00

348 lines
16 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(80)
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 \(record.recordType) Record…") { edit(record) }.disabled(record.kind == nil || 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.kind != nil, !store.isBusy { edit(record) }
}
.overlay {
if filteredRecords.isEmpty {
ContentUnavailableView("No DNS Records", systemImage: "globe", description: Text(search.isEmpty ? "Add a DNS 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 DNS Record").disabled(selectedRecord?.kind == nil || 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 kind: DNSRecordKind
@State private var domain: String
@State private var target: String
@State private var ttl: String
@State private var priority: String
@State private var weight: String
@State private var port: String
@State private var service: String
@State private var srvProtocol: String
@State private var error: String?
init(store: UniFiStore, record: DNSRecord, isNew: Bool) {
self.store = store
self.isNew = isNew
_record = State(initialValue: record)
_kind = State(initialValue: record.kind ?? .a)
_domain = State(initialValue: record.domain ?? "")
_target = State(initialValue: record.target ?? "")
_ttl = State(initialValue: String(record.ttlSeconds ?? 14400))
_priority = State(initialValue: String(record.priority ?? 10))
_weight = State(initialValue: String(record.weight ?? 0))
_port = State(initialValue: record.port.map(String.init) ?? "")
_service = State(initialValue: record.service ?? "")
_srvProtocol = State(initialValue: record.protocol ?? "_tcp")
}
/// Builds the record from the form, keeping only the fields that belong to the selected type.
private var draft: DNSRecord {
var draft = DNSRecord(id: record.id, type: kind.rawValue, enabled: record.enabled,
domain: Self.normalizedDomain(domain))
draft.target = kind == .txt ? target : kind.targetIsDomain
? Self.normalizedDomain(target) : target.trimmingCharacters(in: .whitespacesAndNewlines)
if kind.usesTTL { draft.ttlSeconds = Int(ttl) }
if kind.usesPriority { draft.priority = Int(priority) }
if kind == .srv {
draft.weight = Int(weight)
draft.port = Int(port)
draft.service = Self.normalizedServiceLabel(service)
draft.protocol = Self.normalizedServiceLabel(srvProtocol)
}
return draft
}
private var valid: Bool { (try? draft.payload()) != nil }
private var protocolOptions: [String] {
["_tcp", "_udp"].contains(srvProtocol) ? ["_tcp", "_udp"] : ["_tcp", "_udp", srvProtocol]
}
private static func normalizedDomain(_ value: String) -> String {
var value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if value.hasSuffix(".") { value.removeLast() }
return value
}
private static func normalizedServiceLabel(_ value: String) -> String {
let value = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return value.isEmpty || value.hasPrefix("_") ? value : "_" + value
}
var body: some View {
VStack(spacing: 0) {
HStack {
Text(isNew ? "New DNS Record" : "Edit DNS Record").font(.headline)
Spacer()
}.padding(20)
Form {
Section(kind == .forwardDomain ? "Forward Domain" : "\(kind.label) Record") {
LabeledContent("Site", value: store.sites.first { $0.id == store.selectedSiteID }?.name ?? "")
Picker("Type", selection: $kind) {
ForEach(DNSRecordKind.allCases) { kind in
Text(kind == .forwardDomain ? "Forward Domain" : kind.label).tag(kind)
}
}
.disabled(!isNew)
if kind == .srv {
TextField("Service", text: $service, prompt: Text("_ldap"))
Picker("Protocol", selection: $srvProtocol) {
ForEach(protocolOptions, id: \.self) { Text($0).tag($0) }
}
}
TextField("Domain", text: $domain, prompt: Text(kind == .srv ? "example.com" : "app.example.com"))
if kind == .txt {
TextField(kind.targetLabel, text: $target, prompt: Text(kind.targetPrompt), axis: .vertical)
.lineLimit(1...4)
} else {
TextField(kind.targetLabel, text: $target, prompt: Text(kind.targetPrompt))
}
if kind.usesPriority { TextField("Priority", text: $priority) }
if kind == .srv {
TextField("Weight", text: $weight)
TextField("Port", text: $port, prompt: Text("389"))
}
if kind.usesTTL { TextField("TTL (seconds)", text: $ttl) }
Toggle("Enabled", isOn: $record.enabled)
}
Section {
Text(kind.helpText)
.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
do {
try await store.save(draft, isNew: isNew)
dismiss()
} catch { self.error = error.localizedDescription }
}
}.keyboardShortcut(.defaultAction).disabled(!valid || store.isBusy)
}.padding(20)
}
.frame(width: 500, height: kind == .srv ? 620 : 480)
.interactiveDismissDisabled(store.isBusy)
// A value typed for one type (such as an IPv4 address) rarely fits another.
.onChange(of: kind) { target = "" }
}
}
private extension DNSRecordKind {
var targetLabel: String {
switch self {
case .a: "IPv4 Address"
case .aaaa: "IPv6 Address"
case .cname: "Target Domain"
case .mx: "Mail Server"
case .txt: "Text"
case .srv: "Target Server"
case .forwardDomain: "DNS Server"
}
}
var targetPrompt: String {
switch self {
case .a: "192.168.1.20"
case .aaaa: "fd00::20"
case .cname: "server.example.com"
case .mx: "mail.example.com"
case .txt: "v=spf1 -all"
case .srv: "server.example.com"
case .forwardDomain: "192.168.1.53"
}
}
var helpText: String {
switch self {
case .a: "TTL must be between 0 and 86400 seconds. For a proxied service, use the IP address of your reverse proxy."
case .aaaa: "TTL must be between 0 and 86400 seconds. For a proxied service, use the IPv6 address of your reverse proxy."
case .cname: "The domain resolves to the same address as the target domain. TTL must be between 0 and 86400 seconds."
case .mx: "Mail servers with lower priority values are preferred. Priority must be between 0 and 65535."
case .txt: "TXT records hold free-form text, commonly used for domain verification and SPF."
case .srv: "The record is published as _service._protocol.domain. Lower priority values are preferred; weight balances servers with the same priority."
case .forwardDomain: "Queries for this domain and its subdomains are forwarded to the DNS server instead of the gateway's upstream resolvers."
}
}
}
#Preview("UniFi DNS") {
NavigationStack {
UniFiDNSView(store: UniFiStore(), search: .constant(""))
.navigationTitle("UniFi DNS")
}
.frame(width: 980, height: 650)
}