krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.8.2: 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}