krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.8.1: DomainDig/IntegrationsView.swift · raw

  1import SwiftUI
  2
  3struct IntegrationsSettingsView: View {
  4    @State private var integrationService = IntegrationService.shared
  5    @State private var editingTarget: IntegrationTarget?
  6    @State private var showingCreateSheet = false
  7
  8    var body: some View {
  9        List {
 10            Section("Overview") {
 11                LabeledContent("Integrations", value: "\(integrationService.targets.count)")
 12                LabeledContent("Queued Deliveries", value: "\(integrationService.queue.count)")
 13                LabeledContent("Recent Log Entries", value: "\(integrationService.deliveryRecords.count)")
 14
 15                if let statusMessage = integrationService.statusMessage {
 16                    Text(statusMessage)
 17                        .font(.caption)
 18                        .foregroundStyle(.secondary)
 19                }
 20
 21                Button("Process Queue Now") {
 22                    integrationService.processQueueNow()
 23                }
 24            }
 25
 26            Section("Targets") {
 27                if integrationService.targets.isEmpty {
 28                    Text("No integrations configured.")
 29                        .foregroundStyle(.secondary)
 30                } else {
 31                    ForEach(integrationService.targets) { target in
 32                        NavigationLink {
 33                            IntegrationDetailView(
 34                                integrationID: target.id,
 35                                onEdit: {
 36                                    editingTarget = target
 37                                }
 38                            )
 39                        } label: {
 40                            VStack(alignment: .leading, spacing: 4) {
 41                                HStack {
 42                                    Text(target.name)
 43                                    Spacer()
 44                                    Text(target.type.title)
 45                                        .foregroundStyle(.secondary)
 46                                }
 47
 48                                Text(summary(for: target))
 49                                    .font(.caption)
 50                                    .foregroundStyle(.secondary)
 51
 52                                if !target.isEnabled {
 53                                    Text("Disabled")
 54                                        .font(.caption2)
 55                                        .foregroundStyle(.orange)
 56                                }
 57                            }
 58                        }
 59                    }
 60                }
 61
 62                Button("Add Integration") {
 63                    showingCreateSheet = true
 64                }
 65            }
 66        }
 67        .navigationTitle("Integrations")
 68        .sheet(isPresented: $showingCreateSheet) {
 69            NavigationStack {
 70                IntegrationEditorView(existingTarget: nil)
 71            }
 72        }
 73        .sheet(item: $editingTarget) { target in
 74            NavigationStack {
 75                IntegrationEditorView(existingTarget: target)
 76            }
 77        }
 78        .onAppear {
 79            integrationService.refresh()
 80        }
 81    }
 82
 83    private func summary(for target: IntegrationTarget) -> String {
 84        switch target.configuration {
 85        case .webhook(let configuration):
 86            return configuration.endpointDisplayHost.isEmpty ? "Webhook" : configuration.endpointDisplayHost
 87        case .slack(let configuration):
 88            return configuration.destinationLabel
 89        case .email(let configuration):
 90            return configuration.recipientAddresses.joined(separator: ", ")
 91        }
 92    }
 93}
 94
 95private struct IntegrationDetailView: View {
 96    @Environment(\.dismiss) private var dismiss
 97    @State private var integrationService = IntegrationService.shared
 98
 99    let integrationID: UUID
100    let onEdit: () -> Void
101
102    private var target: IntegrationTarget? {
103        integrationService.targets.first(where: { $0.id == integrationID })
104    }
105
106    var body: some View {
107        List {
108            if let target {
109                Section("Configuration") {
110                    LabeledContent("Type", value: target.type.title)
111                    LabeledContent("Status", value: target.isEnabled ? "Enabled" : "Disabled")
112                    LabeledContent("Destination", value: destination(for: target))
113                    LabeledContent("Minimum Severity", value: target.filters.minimumSeverity.title)
114                    if !target.filters.domains.isEmpty {
115                        LabeledContent("Domains", value: target.filters.domains.joined(separator: ", "))
116                    }
117                }
118
119                Section("Actions") {
120                    Button("Edit Integration") {
121                        onEdit()
122                    }
123
124                    Button("Send Test Event") {
125                        integrationService.sendTest(for: target.id)
126                    }
127
128                    Button(target.isEnabled ? "Disable" : "Enable") {
129                        integrationService.setEnabled(!target.isEnabled, for: target.id)
130                    }
131
132                    Button("Delete Integration", role: .destructive) {
133                        integrationService.delete(targetID: target.id)
134                        dismiss()
135                    }
136                }
137
138                Section("Delivery Log") {
139                    if integrationService.deliveryRecords(for: target.id).isEmpty {
140                        Text("No deliveries yet.")
141                            .foregroundStyle(.secondary)
142                    } else {
143                        ForEach(integrationService.deliveryRecords(for: target.id), id: \.id) { record in
144                            VStack(alignment: .leading, spacing: 4) {
145                                HStack {
146                                    Text(record.status.title)
147                                    Spacer()
148                                    Text(record.timestamp.formatted(date: .abbreviated, time: .shortened))
149                                        .font(.caption)
150                                        .foregroundStyle(.secondary)
151                                }
152
153                                Text(record.summary)
154                                    .font(.subheadline)
155
156                                Text(record.destination)
157                                    .font(.caption)
158                                    .foregroundStyle(.secondary)
159
160                                if let failureReason = record.failureReason {
161                                    let failureColor: Color = record.status == .skipped ? .secondary : .red
162                                    Text(failureReason)
163                                        .font(.caption)
164                                        .foregroundStyle(failureColor)
165                                }
166                            }
167                        }
168                    }
169                }
170            } else {
171                Text("Integration not found.")
172                    .foregroundStyle(.secondary)
173            }
174        }
175        .navigationTitle(target?.name ?? "Integration")
176    }
177
178    private func destination(for target: IntegrationTarget) -> String {
179        switch target.configuration {
180        case .webhook(let configuration):
181            return configuration.endpointDisplayHost
182        case .slack(let configuration):
183            return configuration.destinationLabel
184        case .email(let configuration):
185            return configuration.recipientAddresses.joined(separator: ", ")
186        }
187    }
188}
189
190private struct IntegrationEditorView: View {
191    @Environment(\.dismiss) private var dismiss
192    @State private var integrationService = IntegrationService.shared
193
194    let existingTarget: IntegrationTarget?
195
196    @State private var type: IntegrationType = .webhook
197    @State private var name: String = ""
198    @State private var isEnabled = true
199    @State private var minimumSeverity: EventSeverity = .warning
200    @State private var selectedEventTypes: Set<MonitoringEventType> = Set(MonitoringEventType.allCases.filter { $0 != .test })
201    @State private var domainsText = ""
202
203    @State private var webhookURL = ""
204    @State private var slackWebhookURL = ""
205    @State private var emailHost = ""
206    @State private var emailPort = "465"
207    @State private var emailUsername = ""
208    @State private var emailPassword = ""
209    @State private var senderAddress = ""
210    @State private var recipientAddresses = ""
211    @State private var smtpSecurity: SMTPSecurityMode = .directTLS
212
213    @State private var validationMessage: String?
214
215    var body: some View {
216        Form {
217            Section("Integration") {
218                Picker("Type", selection: $type) {
219                    ForEach(IntegrationType.allCases) { integrationType in
220                        Text(integrationType.title).tag(integrationType)
221                    }
222                }
223                .disabled(existingTarget != nil)
224
225                TextField("Name", text: $name)
226                Toggle("Enabled", isOn: $isEnabled)
227            }
228
229            Section("Routing Rules") {
230                Picker("Minimum Severity", selection: $minimumSeverity) {
231                    ForEach(EventSeverity.allCases) { severity in
232                        Text(severity.title).tag(severity)
233                    }
234                }
235
236                TextField("Domains (comma-separated)", text: $domainsText)
237                    .textInputAutocapitalization(.never)
238                    .autocorrectionDisabled()
239
240                ForEach(MonitoringEventType.allCases.filter { $0 != .test }, id: \.self) { eventType in
241                    Toggle(
242                        eventType.title,
243                        isOn: Binding(
244                            get: { selectedEventTypes.contains(eventType) },
245                            set: { isSelected in
246                                if isSelected {
247                                    selectedEventTypes.insert(eventType)
248                                } else {
249                                    selectedEventTypes.remove(eventType)
250                                }
251                            }
252                        )
253                    )
254                }
255            }
256
257            switch type {
258            case .webhook:
259                Section("Webhook") {
260                    TextField("https://example.com/webhook", text: $webhookURL)
261                        .textInputAutocapitalization(.never)
262                        .autocorrectionDisabled()
263                        .keyboardType(.URL)
264
265                    if existingTarget != nil {
266                        Text("Saved webhook URL remains in Keychain unless you replace it.")
267                            .font(.caption)
268                            .foregroundStyle(.secondary)
269                    }
270                }
271            case .slack:
272                Section("Slack") {
273                    TextField("https://hooks.slack.com/services/...", text: $slackWebhookURL)
274                        .textInputAutocapitalization(.never)
275                        .autocorrectionDisabled()
276                        .keyboardType(.URL)
277
278                    if existingTarget != nil {
279                        Text("Saved Slack webhook remains in Keychain unless you replace it.")
280                            .font(.caption)
281                            .foregroundStyle(.secondary)
282                    }
283                }
284            case .email:
285                Section("SMTP") {
286                    TextField("SMTP Host", text: $emailHost)
287                        .textInputAutocapitalization(.never)
288                        .autocorrectionDisabled()
289
290                    TextField("Port", text: $emailPort)
291                        .keyboardType(.numberPad)
292
293                    TextField("Username", text: $emailUsername)
294                        .textInputAutocapitalization(.never)
295                        .autocorrectionDisabled()
296
297                    SecureField(existingTarget == nil ? "Password" : "Replace Password", text: $emailPassword)
298
299                    TextField("Sender Address", text: $senderAddress)
300                        .textInputAutocapitalization(.never)
301                        .autocorrectionDisabled()
302                        .keyboardType(.emailAddress)
303
304                    TextField("Recipients (comma-separated)", text: $recipientAddresses)
305                        .textInputAutocapitalization(.never)
306                        .autocorrectionDisabled()
307                        .keyboardType(.emailAddress)
308
309                    Picker("Security", selection: $smtpSecurity) {
310                        ForEach(SMTPSecurityMode.allCases) { mode in
311                            Text(mode.title).tag(mode)
312                        }
313                    }
314
315                    if existingTarget != nil {
316                        Text("Saved SMTP password remains in Keychain unless you replace it.")
317                            .font(.caption)
318                            .foregroundStyle(.secondary)
319                    }
320                }
321            }
322
323            if let validationMessage {
324                Section {
325                    Text(validationMessage)
326                        .font(.caption)
327                        .foregroundStyle(.red)
328                }
329            }
330        }
331        .navigationTitle(existingTarget == nil ? "Add Integration" : "Edit Integration")
332        .toolbar {
333            ToolbarItem(placement: .cancellationAction) {
334                Button("Cancel") {
335                    dismiss()
336                }
337            }
338
339            ToolbarItem(placement: .confirmationAction) {
340                Button("Save") {
341                    save()
342                }
343            }
344        }
345        .onAppear {
346            populateFromExisting()
347        }
348    }
349
350    private func populateFromExisting() {
351        guard let existingTarget else { return }
352        type = existingTarget.type
353        name = existingTarget.name
354        isEnabled = existingTarget.isEnabled
355        minimumSeverity = existingTarget.filters.minimumSeverity
356        selectedEventTypes = existingTarget.filters.eventTypes
357        domainsText = existingTarget.filters.domains.joined(separator: ", ")
358
359        switch existingTarget.configuration {
360        case .webhook:
361            break
362        case .slack:
363            break
364        case .email(let configuration):
365            emailHost = configuration.smtpHost
366            emailPort = String(configuration.port)
367            emailUsername = configuration.username
368            senderAddress = configuration.senderAddress
369            recipientAddresses = configuration.recipientAddresses.joined(separator: ", ")
370            smtpSecurity = configuration.securityMode
371        }
372    }
373
374    private func save() {
375        validationMessage = nil
376
377        let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
378        guard !trimmedName.isEmpty else {
379            validationMessage = "Name is required."
380            return
381        }
382
383        let filters = IntegrationFilterSet(
384            minimumSeverity: minimumSeverity,
385            eventTypes: selectedEventTypes,
386            domains: domainsText
387                .split(separator: ",")
388                .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
389                .filter { !$0.isEmpty }
390        )
391
392        let targetID = existingTarget?.id ?? UUID()
393
394        do {
395            switch type {
396            case .webhook:
397                let existingReference: String? = {
398                    guard case .webhook(let configuration) = existingTarget?.configuration else { return nil }
399                    return configuration.credentialReference
400                }()
401                if webhookURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
402                    validationMessage = "Webhook URL is required."
403                    return
404                }
405
406                let target = IntegrationTarget(
407                    id: targetID,
408                    type: .webhook,
409                    name: trimmedName,
410                    isEnabled: isEnabled,
411                    configuration: .webhook(
412                        WebhookIntegrationConfiguration(
413                            endpointDisplayHost: existingWebhookDisplayHost(),
414                            timeoutSeconds: 15,
415                            additionalHeaders: [:],
416                            credentialReference: existingReference
417                        )
418                    ),
419                    filters: filters
420                )
421                try integrationService.upsert(
422                    target: target,
423                    webhookURL: webhookURL.nilIfBlank
424                )
425            case .slack:
426                let existingReference: String? = {
427                    guard case .slack(let configuration) = existingTarget?.configuration else { return nil }
428                    return configuration.credentialReference
429                }()
430                if slackWebhookURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
431                    validationMessage = "Slack webhook URL is required."
432                    return
433                }
434
435                let target = IntegrationTarget(
436                    id: targetID,
437                    type: .slack,
438                    name: trimmedName,
439                    isEnabled: isEnabled,
440                    configuration: .slack(
441                        SlackIntegrationConfiguration(
442                            destinationLabel: existingSlackDestination(),
443                            credentialReference: existingReference
444                        )
445                    ),
446                    filters: filters
447                )
448                try integrationService.upsert(
449                    target: target,
450                    slackWebhookURL: slackWebhookURL.nilIfBlank
451                )
452            case .email:
453                guard let port = Int(emailPort) else {
454                    validationMessage = "SMTP port must be a number."
455                    return
456                }
457
458                let recipients = recipientAddresses
459                    .split(separator: ",")
460                    .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
461                    .filter { !$0.isEmpty }
462
463                let existingReference: String? = {
464                    guard case .email(let configuration) = existingTarget?.configuration else { return nil }
465                    return configuration.credentialReference
466                }()
467                if emailPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && existingReference == nil {
468                    validationMessage = "SMTP password is required."
469                    return
470                }
471
472                let target = IntegrationTarget(
473                    id: targetID,
474                    type: .email,
475                    name: trimmedName,
476                    isEnabled: isEnabled,
477                    configuration: .email(
478                        EmailIntegrationConfiguration(
479                            smtpHost: emailHost.trimmingCharacters(in: .whitespacesAndNewlines),
480                            port: port,
481                            username: emailUsername.trimmingCharacters(in: .whitespacesAndNewlines),
482                            senderAddress: senderAddress.trimmingCharacters(in: .whitespacesAndNewlines),
483                            recipientAddresses: recipients,
484                            securityMode: smtpSecurity,
485                            credentialReference: existingReference
486                        )
487                    ),
488                    filters: filters
489                )
490                try integrationService.upsert(
491                    target: target,
492                    emailPassword: emailPassword.nilIfBlank
493                )
494            }
495
496            dismiss()
497        } catch {
498            validationMessage = error.localizedDescription
499        }
500    }
501
502    private func existingWebhookDisplayHost() -> String {
503        guard case .webhook(let configuration) = existingTarget?.configuration else {
504            return ""
505        }
506        return configuration.endpointDisplayHost
507    }
508
509    private func existingSlackDestination() -> String {
510        guard case .slack(let configuration) = existingTarget?.configuration else {
511            return "Slack"
512        }
513        return configuration.destinationLabel
514    }
515}
516
517private extension String {
518    var nilIfBlank: String? {
519        let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
520        return trimmed.isEmpty ? nil : trimmed
521    }
522}