krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.4.1: DomainDig/WorkflowsView.swift · raw
1import SwiftUI
2
3private enum WorkflowDestinationMode: String, CaseIterable, Identifiable {
4 case existing
5 case new
6
7 var id: String { rawValue }
8}
9
10struct WorkflowsView: View {
11 @Environment(\.appDensity) private var appDensity
12 @Bindable var viewModel: DomainViewModel
13 @State private var purchaseService = PurchaseService.shared
14
15 @State private var showingCreateWorkflow = false
16
17 var body: some View {
18 let _ = purchaseService.currentTier
19
20 List {
21 if let limitMessage = FeatureAccessService.workflowAllowanceSummary(currentCount: viewModel.workflows.count) {
22 Section {
23 Text(limitMessage)
24 .font(appDensity.font(.caption))
25 .foregroundStyle(.secondary)
26 }
27 .listRowBackground(Color(.systemGray6).opacity(0.5))
28 }
29
30 workflowContent
31 }
32 .scrollContentBackground(.hidden)
33 .background(Color.black)
34 .refreshable {
35 await viewModel.refreshWorkflowList()
36 }
37 .navigationTitle("Workflows")
38 .toolbar {
39 ToolbarItemGroup(placement: .topBarTrailing) {
40 Button {
41 showingCreateWorkflow = true
42 } label: {
43 Image(systemName: "plus.circle")
44 }
45
46 if !viewModel.workflows.isEmpty {
47 EditButton()
48 }
49 }
50 }
51 .sheet(isPresented: $showingCreateWorkflow) {
52 WorkflowComposerView(viewModel: viewModel)
53 }
54 .sheet(item: workflowSummaryBinding) { summary in
55 WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
56 }
57 .preferredColorScheme(.dark)
58 }
59
60 private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
61 Binding(
62 get: { viewModel.latestWorkflowRunSummary },
63 set: { viewModel.latestWorkflowRunSummary = $0 }
64 )
65 }
66
67 @ViewBuilder
68 private var workflowContent: some View {
69 if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
70 Section("Workflow Run") {
71 VStack(alignment: .leading, spacing: 8) {
72 ProgressView(
73 value: Double(viewModel.batchCompletedCount),
74 total: Double(max(viewModel.batchTotalCount, 1))
75 )
76 .tint(.cyan)
77
78 HStack {
79 Text(viewModel.batchProgressLabel)
80 .font(appDensity.font(.caption))
81 .foregroundStyle(.secondary)
82 Spacer()
83 if viewModel.batchLookupRunning {
84 Button("Cancel") {
85 viewModel.cancelBatchLookup()
86 }
87 .buttonStyle(.bordered)
88 .font(appDensity.font(.caption2))
89 }
90 }
91 }
92 .padding(.vertical, 4)
93 }
94 .listRowBackground(Color(.systemGray6).opacity(0.5))
95 }
96
97 if viewModel.workflows.isEmpty {
98 Section {
99 EmptyStateCardView(
100 title: "No Workflows Yet",
101 message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.",
102 suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.",
103 systemImage: "square.stack.3d.down.right",
104 showsCardBackground: false
105 )
106 }
107 .listRowBackground(Color(.systemGray6).opacity(0.5))
108 } else {
109 ForEach(viewModel.workflows) { workflow in
110 NavigationLink {
111 WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id)
112 } label: {
113 WorkflowRowView(workflow: workflow)
114 }
115 .listRowBackground(Color(.systemGray6).opacity(0.5))
116 }
117 .onDelete { offsets in
118 let workflows = offsets.map { viewModel.workflows[$0] }
119 workflows.forEach(viewModel.deleteWorkflow)
120 }
121 }
122 }
123}
124
125private struct WorkflowRowView: View {
126 @Environment(\.appDensity) private var appDensity
127
128 let workflow: DomainWorkflow
129
130 var body: some View {
131 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
132 HStack(alignment: .firstTextBaseline, spacing: 8) {
133 if workflow.collaboration?.isShared == true {
134 Image(systemName: "person.2.fill")
135 .font(.caption2)
136 .foregroundStyle(.cyan)
137 }
138 Text(workflow.name)
139 .font(appDensity.font(.callout, design: .default, weight: .semibold))
140 .foregroundStyle(.primary)
141 .lineLimit(2)
142 Spacer(minLength: 8)
143 Text("\(workflow.domains.count) domains")
144 .font(appDensity.font(.caption2))
145 .foregroundStyle(.secondary)
146 }
147
148 Text("Updated \(workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))")
149 .font(appDensity.font(.caption2))
150 .foregroundStyle(.secondary)
151
152 if let collaboration = workflow.collaboration, collaboration.isShared {
153 Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
154 .font(appDensity.font(.caption2))
155 .foregroundStyle(.secondary)
156 }
157
158 if let notes = workflow.notes, !notes.isEmpty {
159 Text(notes)
160 .font(appDensity.font(.caption))
161 .foregroundStyle(.secondary)
162 .lineLimit(2)
163 }
164 }
165 .frame(maxWidth: .infinity, alignment: .leading)
166 .padding(.vertical, 4)
167 }
168}
169
170struct WorkflowDetailView: View {
171 @Environment(\.appDensity) private var appDensity
172 @Bindable var viewModel: DomainViewModel
173 let workflowID: UUID
174
175 @State private var showingEditor = false
176 @State private var draftDomain = ""
177 @State private var includeAllExports = false
178 @State private var shareEntity: ShareableEntity?
179
180 private var workflow: DomainWorkflow? {
181 viewModel.workflow(withID: workflowID)
182 }
183
184 private var latestSummary: WorkflowRunSummary? {
185 guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
186 return viewModel.latestWorkflowRunSummary
187 }
188
189 var body: some View {
190 Group {
191 if let workflow {
192 List {
193 Section("Overview") {
194 statRow(label: "Domains", value: "\(workflow.domains.count)")
195 statRow(label: "Created", value: workflow.createdAt.formatted(date: .abbreviated, time: .shortened))
196 statRow(label: "Updated", value: workflow.updatedAt.formatted(date: .abbreviated, time: .shortened))
197 if let notes = workflow.notes, !notes.isEmpty {
198 Text(notes)
199 .font(appDensity.font(.caption))
200 .foregroundStyle(.secondary)
201 }
202 if let collaboration = workflow.collaboration, collaboration.isShared {
203 Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
204 .font(appDensity.font(.caption))
205 .foregroundStyle(.secondary)
206 }
207 }
208
209 Section("Actions") {
210 Button {
211 viewModel.runWorkflow(workflow)
212 } label: {
213 Label("Run Workflow", systemImage: "play.fill")
214 }
215 .disabled(workflow.domains.isEmpty || viewModel.batchLookupRunning)
216
217 if viewModel.batchLookupRunning, viewModel.batchLookupSource == .workflow {
218 Button("Cancel Workflow Run", role: .destructive) {
219 viewModel.cancelBatchLookup()
220 }
221 }
222
223 Button {
224 showingEditor = true
225 } label: {
226 Label("Edit Workflow", systemImage: "pencil")
227 }
228 .disabled(!viewModel.canEdit(workflow))
229
230 Button {
231 shareEntity = .workflow(workflow.id)
232 } label: {
233 Label(workflow.collaboration?.isShared == true ? "Manage Share" : "Share Workflow", systemImage: "person.2")
234 }
235
236 if latestSummary != nil {
237 Toggle("Export all domains", isOn: $includeAllExports)
238 Menu {
239 Button("Export TXT") {
240 shareWorkflowResults(format: .text)
241 }
242 if FeatureAccessService.hasAccess(to: .advancedExports) {
243 Button("Export CSV") {
244 shareWorkflowResults(format: .csv)
245 }
246 Button("Export JSON") {
247 shareWorkflowResults(format: .json)
248 }
249 } else {
250 Button("CSV Export • Available in Pro") {}
251 .disabled(true)
252 Button("JSON Export • Available in Pro") {}
253 .disabled(true)
254 }
255 } label: {
256 Label("Export Results", systemImage: "square.and.arrow.up")
257 }
258 }
259 }
260
261 if let latestSummary {
262 Section("Latest Run") {
263 statRow(label: "Processed", value: "\(latestSummary.totalDomains)")
264 statRow(label: "Changed", value: "\(latestSummary.changedDomains)")
265 statRow(label: "Warnings", value: "\(latestSummary.warningDomains)")
266 statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)")
267 if !latestSummary.workflowInsights.isEmpty {
268 Text(latestSummary.workflowInsights[0].description)
269 .font(appDensity.font(.caption))
270 .foregroundStyle(.secondary)
271 }
272
273 Button {
274 viewModel.latestWorkflowRunSummary = latestSummary
275 } label: {
276 Label("View Run Summary", systemImage: "list.bullet.rectangle")
277 }
278 }
279 }
280
281 Section("Add Domain") {
282 HStack {
283 TextField("example.com", text: $draftDomain)
284 .textInputAutocapitalization(.never)
285 .autocorrectionDisabled()
286 Button("Add") {
287 guard !draftDomain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
288 viewModel.addDomains([draftDomain], to: workflow)
289 draftDomain = ""
290 }
291 .disabled(!viewModel.canEdit(workflow))
292 }
293 }
294
295 Section("Domains") {
296 if workflow.domains.isEmpty {
297 Text("No domains in this workflow")
298 .font(appDensity.font(.caption))
299 .foregroundStyle(.secondary)
300 } else {
301 ForEach(workflow.domains, id: \.self) { domain in
302 Button {
303 viewModel.openInspection(for: domain)
304 } label: {
305 HStack {
306 Text(domain)
307 .font(appDensity.font(.callout))
308 .foregroundStyle(.primary)
309 Spacer()
310 Image(systemName: "arrow.up.right.circle")
311 .foregroundStyle(.secondary)
312 }
313 }
314 .buttonStyle(.plain)
315 }
316 .onDelete { offsets in
317 guard viewModel.canEdit(workflow) else { return }
318 viewModel.removeWorkflowDomains(at: offsets, from: workflow)
319 }
320 .onMove { offsets, destination in
321 guard viewModel.canEdit(workflow) else { return }
322 viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow)
323 }
324 }
325 }
326 }
327 .scrollContentBackground(.hidden)
328 .background(Color.black)
329 .navigationTitle(workflow.name)
330 .toolbar {
331 if !workflow.domains.isEmpty {
332 EditButton()
333 .disabled(!viewModel.canEdit(workflow))
334 }
335 }
336 .sheet(isPresented: $showingEditor) {
337 WorkflowComposerView(viewModel: viewModel, workflow: workflow)
338 }
339 .sheet(item: workflowSummaryBinding) { summary in
340 WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
341 }
342 .sheet(item: $shareEntity) { entity in
343 CloudSharingSheet(entity: entity, title: workflow.name)
344 }
345 } else {
346 Text("Workflow not found")
347 .font(appDensity.font(.callout))
348 .foregroundStyle(.secondary)
349 }
350 }
351 .preferredColorScheme(.dark)
352 }
353
354 private func statRow(label: String, value: String) -> some View {
355 HStack {
356 Text(label)
357 .font(appDensity.font(.caption))
358 .foregroundStyle(.secondary)
359 Spacer()
360 Text(value)
361 .font(appDensity.font(.callout))
362 .foregroundStyle(.primary)
363 }
364 }
365
366 private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
367 Binding(
368 get: {
369 guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
370 return viewModel.latestWorkflowRunSummary
371 },
372 set: { _ in
373 viewModel.latestWorkflowRunSummary = nil
374 }
375 )
376 }
377
378 private func shareWorkflowResults(format: DomainExportFormat) {
379 guard let latestSummary else { return }
380
381 let changedOnly = !includeAllExports
382 let formatter = DateFormatter()
383 formatter.dateFormat = "yyyyMMdd_HHmmss"
384 let timestamp = formatter.string(from: Date())
385 let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)"
386 let data: Data
387
388 switch format {
389 case .text:
390 data = Data(viewModel.exportWorkflowText(summary: latestSummary, changedOnly: changedOnly).utf8)
391 case .csv:
392 data = Data(viewModel.exportWorkflowCSV(summary: latestSummary, changedOnly: changedOnly).utf8)
393 case .json:
394 data = viewModel.exportWorkflowJSONData(summary: latestSummary, changedOnly: changedOnly) ?? Data("[]".utf8)
395 }
396
397 ExportPresenter.share(filename: filename, data: data)
398 }
399}
400
401struct WorkflowComposerView: View {
402 @Environment(\.dismiss) private var dismiss
403 @Bindable var viewModel: DomainViewModel
404 let workflow: DomainWorkflow?
405
406 @State private var name = ""
407 @State private var domainsText = ""
408 @State private var notes = ""
409
410 init(viewModel: DomainViewModel, workflow: DomainWorkflow? = nil) {
411 self.viewModel = viewModel
412 self.workflow = workflow
413 _name = State(initialValue: workflow?.name ?? "")
414 _domainsText = State(initialValue: workflow?.domains.joined(separator: "\n") ?? "")
415 _notes = State(initialValue: workflow?.notes ?? "")
416 }
417
418 var body: some View {
419 NavigationStack {
420 Form {
421 Section("Workflow") {
422 TextField("Name", text: $name)
423 TextField("Notes", text: $notes, axis: .vertical)
424 .lineLimit(3...6)
425 }
426
427 Section("Domains") {
428 TextField("example.com\napple.com", text: $domainsText, axis: .vertical)
429 .textInputAutocapitalization(.never)
430 .autocorrectionDisabled()
431 .lineLimit(6...14)
432 }
433 }
434 .navigationTitle(workflow == nil ? "New Workflow" : "Edit Workflow")
435 .toolbar {
436 ToolbarItem(placement: .cancellationAction) {
437 Button("Cancel") {
438 dismiss()
439 }
440 }
441 ToolbarItem(placement: .confirmationAction) {
442 Button("Save") {
443 saveWorkflow()
444 }
445 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || parsedDomains.isEmpty)
446 }
447 }
448 }
449 .preferredColorScheme(.dark)
450 }
451
452 private var parsedDomains: [String] {
453 domainsText
454 .components(separatedBy: CharacterSet(charactersIn: ",\n"))
455 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
456 .filter { !$0.isEmpty }
457 }
458
459 private func saveWorkflow() {
460 if let workflow {
461 viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
462 dismiss()
463 return
464 }
465
466 if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil {
467 dismiss()
468 }
469 }
470}
471
472struct WorkflowRunSummaryView: View {
473 @Bindable var viewModel: DomainViewModel
474 let summary: WorkflowRunSummary
475
476 @State private var showAllResults = false
477
478 private var visibleResults: [BatchLookupResult] {
479 showAllResults ? summary.results : summary.results.filter(\.hasMeaningfulChange)
480 }
481
482 var body: some View {
483 NavigationStack {
484 List {
485 Section("Overview") {
486 statRow(label: "Workflow", value: summary.workflowName)
487 statRow(label: "Processed", value: "\(summary.totalDomains)")
488 statRow(label: "Changed", value: "\(summary.changedDomains)")
489 statRow(label: "Warnings", value: "\(summary.warningDomains)")
490 statRow(label: "Unchanged", value: "\(summary.unchangedDomains)")
491 statRow(label: "Finished", value: summary.generatedAt.formatted(date: .abbreviated, time: .shortened))
492 }
493
494 Section {
495 Toggle("Show unchanged domains", isOn: $showAllResults)
496 }
497
498 if !summary.workflowInsights.isEmpty {
499 Section("Workflow Insights") {
500 ForEach(summary.workflowInsights) { insight in
501 VStack(alignment: .leading, spacing: 4) {
502 Text(insight.description)
503 .font(.system(.callout, design: .monospaced))
504 .foregroundStyle(.primary)
505 Text(insight.domainsInvolved.joined(separator: ", "))
506 .font(.system(.caption, design: .monospaced))
507 .foregroundStyle(.secondary)
508 }
509 }
510 }
511 }
512
513 Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") {
514 if visibleResults.isEmpty {
515 Text("No domains with meaningful changes or warnings")
516 .font(.system(.caption, design: .monospaced))
517 .foregroundStyle(.secondary)
518 } else {
519 ForEach(visibleResults) { result in
520 if let entry = viewModel.historyEntry(for: result) {
521 NavigationLink {
522 HistoryDetailView(viewModel: viewModel, entry: entry)
523 } label: {
524 BatchResultRowView(result: result)
525 }
526 } else {
527 BatchResultRowView(result: result)
528 }
529 }
530 }
531 }
532 }
533 .navigationTitle("Workflow Summary")
534 .toolbar {
535 ToolbarItemGroup(placement: .topBarTrailing) {
536 Menu {
537 Button("Export TXT") {
538 share(format: .text)
539 }
540 if FeatureAccessService.hasAccess(to: .advancedExports) {
541 Button("Export CSV") {
542 share(format: .csv)
543 }
544 Button("Export JSON") {
545 share(format: .json)
546 }
547 } else {
548 Button("CSV Export • Available in Pro") {}
549 .disabled(true)
550 Button("JSON Export • Available in Pro") {}
551 .disabled(true)
552 }
553 } label: {
554 Image(systemName: "square.and.arrow.up")
555 }
556
557 if let workflow = viewModel.workflow(withID: summary.workflowID) {
558 Button {
559 viewModel.runWorkflow(workflow)
560 } label: {
561 Image(systemName: "arrow.clockwise")
562 }
563 .disabled(viewModel.batchLookupRunning)
564 }
565 }
566 }
567 }
568 .preferredColorScheme(.dark)
569 }
570
571 private func statRow(label: String, value: String) -> some View {
572 HStack {
573 Text(label)
574 .font(.system(.caption, design: .monospaced))
575 .foregroundStyle(.secondary)
576 Spacer()
577 Text(value)
578 .font(.system(.callout, design: .monospaced))
579 .foregroundStyle(.primary)
580 }
581 }
582
583 private func share(format: DomainExportFormat) {
584 let changedOnly = !showAllResults
585 let formatter = DateFormatter()
586 formatter.dateFormat = "yyyyMMdd_HHmmss"
587 let timestamp = formatter.string(from: Date())
588 let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)"
589 let data: Data
590
591 switch format {
592 case .text:
593 data = Data(viewModel.exportWorkflowText(summary: summary, changedOnly: changedOnly).utf8)
594 case .csv:
595 data = Data(viewModel.exportWorkflowCSV(summary: summary, changedOnly: changedOnly).utf8)
596 case .json:
597 data = viewModel.exportWorkflowJSONData(summary: summary, changedOnly: changedOnly) ?? Data("[]".utf8)
598 }
599
600 ExportPresenter.share(filename: filename, data: data)
601 }
602}
603
604struct WorkflowBulkAddSheet: View {
605 @Environment(\.dismiss) private var dismiss
606 @Bindable var viewModel: DomainViewModel
607 let title: String
608 let availableDomains: [String]
609
610 @State private var mode: WorkflowDestinationMode = .existing
611 @State private var selectedDomains: Set<String>
612 @State private var selectedWorkflowID: UUID?
613 @State private var newWorkflowName = ""
614 @State private var newWorkflowNotes = ""
615
616 init(viewModel: DomainViewModel, title: String, availableDomains: [String]) {
617 self.viewModel = viewModel
618 self.title = title
619 self.availableDomains = Array(Set(availableDomains)).sorted()
620 _selectedDomains = State(initialValue: Set(availableDomains))
621 _selectedWorkflowID = State(initialValue: viewModel.workflows.first?.id)
622 }
623
624 var body: some View {
625 NavigationStack {
626 List {
627 Section("Selection") {
628 ForEach(availableDomains, id: \.self) { domain in
629 Button {
630 toggle(domain)
631 } label: {
632 HStack {
633 Text(domain)
634 .foregroundStyle(.primary)
635 Spacer()
636 Image(systemName: selectedDomains.contains(domain) ? "checkmark.circle.fill" : "circle")
637 .foregroundStyle(selectedDomains.contains(domain) ? .cyan : .secondary)
638 }
639 }
640 .buttonStyle(.plain)
641 }
642 }
643
644 Section("Destination") {
645 Picker("Destination", selection: $mode) {
646 Text("Existing").tag(WorkflowDestinationMode.existing)
647 Text("New").tag(WorkflowDestinationMode.new)
648 }
649 .pickerStyle(.segmented)
650
651 if mode == .existing, !viewModel.workflows.isEmpty {
652 Picker("Workflow", selection: $selectedWorkflowID) {
653 ForEach(viewModel.workflows) { workflow in
654 Text(workflow.name).tag(Optional(workflow.id))
655 }
656 }
657 } else {
658 TextField("Workflow name", text: $newWorkflowName)
659 TextField("Notes", text: $newWorkflowNotes, axis: .vertical)
660 .lineLimit(2...5)
661 }
662 }
663 }
664 .navigationTitle(title)
665 .toolbar {
666 ToolbarItem(placement: .cancellationAction) {
667 Button("Cancel") {
668 dismiss()
669 }
670 }
671 ToolbarItem(placement: .confirmationAction) {
672 Button("Save") {
673 save()
674 }
675 .disabled(!canSave)
676 }
677 }
678 .onAppear {
679 if viewModel.workflows.isEmpty {
680 mode = .new
681 }
682 }
683 }
684 .preferredColorScheme(.dark)
685 }
686
687 private var selectedDomainList: [String] {
688 availableDomains.filter { selectedDomains.contains($0) }
689 }
690
691 private var canSave: Bool {
692 guard !selectedDomainList.isEmpty else { return false }
693 switch mode {
694 case .existing:
695 return selectedWorkflowID != nil
696 case .new:
697 return !newWorkflowName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
698 }
699 }
700
701 private func toggle(_ domain: String) {
702 if selectedDomains.contains(domain) {
703 selectedDomains.remove(domain)
704 } else {
705 selectedDomains.insert(domain)
706 }
707 }
708
709 private func save() {
710 switch mode {
711 case .existing:
712 guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
713 viewModel.addDomains(selectedDomainList, to: workflow)
714 dismiss()
715 case .new:
716 guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else {
717 return
718 }
719 dismiss()
720 }
721 }
722}