krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.8.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 Button("Export Markdown") {
250 shareWorkflowResults(format: .markdown)
251 }
252 Button("Export PDF") {
253 shareWorkflowResults(format: .pdf)
254 }
255 } else {
256 Button("CSV Export • Available in Pro") {}
257 .disabled(true)
258 Button("JSON Export • Available in Pro") {}
259 .disabled(true)
260 Button("Markdown Export • Available in Pro") {}
261 .disabled(true)
262 Button("PDF Export • Available in Pro") {}
263 .disabled(true)
264 }
265 } label: {
266 Label("Export Results", systemImage: "square.and.arrow.up")
267 }
268 }
269 }
270
271 if let latestSummary {
272 Section("Latest Run") {
273 statRow(label: "Processed", value: "\(latestSummary.totalDomains)")
274 statRow(label: "Changed", value: "\(latestSummary.changedDomains)")
275 statRow(label: "Warnings", value: "\(latestSummary.warningDomains)")
276 statRow(label: "Unchanged", value: "\(latestSummary.unchangedDomains)")
277 if !latestSummary.workflowInsights.isEmpty {
278 Text(latestSummary.workflowInsights[0].description)
279 .font(appDensity.font(.caption))
280 .foregroundStyle(.secondary)
281 }
282
283 Button {
284 viewModel.latestWorkflowRunSummary = latestSummary
285 } label: {
286 Label("View Run Summary", systemImage: "list.bullet.rectangle")
287 }
288 }
289 }
290
291 Section("Add Domain") {
292 HStack {
293 TextField("example.com", text: $draftDomain)
294 .textInputAutocapitalization(.never)
295 .autocorrectionDisabled()
296 Button("Add") {
297 guard !draftDomain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
298 viewModel.addDomains([draftDomain], to: workflow)
299 draftDomain = ""
300 }
301 .disabled(!viewModel.canEdit(workflow))
302 }
303 }
304
305 Section("Domains") {
306 if workflow.domains.isEmpty {
307 Text("No domains in this workflow")
308 .font(appDensity.font(.caption))
309 .foregroundStyle(.secondary)
310 } else {
311 ForEach(workflow.domains, id: \.self) { domain in
312 Button {
313 viewModel.openInspection(for: domain)
314 } label: {
315 HStack {
316 Text(domain)
317 .font(appDensity.font(.callout))
318 .foregroundStyle(.primary)
319 Spacer()
320 Image(systemName: "arrow.up.right.circle")
321 .foregroundStyle(.secondary)
322 }
323 }
324 .buttonStyle(.plain)
325 }
326 .onDelete { offsets in
327 guard viewModel.canEdit(workflow) else { return }
328 viewModel.removeWorkflowDomains(at: offsets, from: workflow)
329 }
330 .onMove { offsets, destination in
331 guard viewModel.canEdit(workflow) else { return }
332 viewModel.moveWorkflowDomains(from: offsets, to: destination, in: workflow)
333 }
334 }
335 }
336 }
337 .scrollContentBackground(.hidden)
338 .background(Color.black)
339 .navigationTitle(workflow.name)
340 .toolbar {
341 if !workflow.domains.isEmpty {
342 EditButton()
343 .disabled(!viewModel.canEdit(workflow))
344 }
345 }
346 .sheet(isPresented: $showingEditor) {
347 WorkflowComposerView(viewModel: viewModel, workflow: workflow)
348 }
349 .sheet(item: workflowSummaryBinding) { summary in
350 WorkflowRunSummaryView(viewModel: viewModel, summary: summary)
351 }
352 .sheet(item: $shareEntity) { entity in
353 CloudSharingSheet(entity: entity, title: workflow.name)
354 }
355 } else {
356 Text("Workflow not found")
357 .font(appDensity.font(.callout))
358 .foregroundStyle(.secondary)
359 }
360 }
361 .preferredColorScheme(.dark)
362 }
363
364 private func statRow(label: String, value: String) -> some View {
365 HStack {
366 Text(label)
367 .font(appDensity.font(.caption))
368 .foregroundStyle(.secondary)
369 Spacer()
370 Text(value)
371 .font(appDensity.font(.callout))
372 .foregroundStyle(.primary)
373 }
374 }
375
376 private var workflowSummaryBinding: Binding<WorkflowRunSummary?> {
377 Binding(
378 get: {
379 guard viewModel.latestWorkflowRunSummary?.workflowID == workflowID else { return nil }
380 return viewModel.latestWorkflowRunSummary
381 },
382 set: { _ in
383 viewModel.latestWorkflowRunSummary = nil
384 }
385 )
386 }
387
388 private func shareWorkflowResults(format: DomainExportFormat) {
389 guard let latestSummary else { return }
390
391 let changedOnly = !includeAllExports
392 let formatter = DateFormatter()
393 formatter.dateFormat = "yyyyMMdd_HHmmss"
394 let timestamp = formatter.string(from: Date())
395 let filename = "\(timestamp)_workflow_\(latestSummary.workflowName.replacingOccurrences(of: " ", with: "_").lowercased()).\(format.fileExtension)"
396 guard let data = viewModel.exportWorkflowData(summary: latestSummary, changedOnly: changedOnly, format: format) else {
397 return
398 }
399
400 ExportPresenter.share(filename: filename, data: data)
401 }
402}
403
404struct WorkflowComposerView: View {
405 @Environment(\.dismiss) private var dismiss
406 @Bindable var viewModel: DomainViewModel
407 let workflow: DomainWorkflow?
408
409 @State private var name = ""
410 @State private var domainsText = ""
411 @State private var notes = ""
412
413 init(viewModel: DomainViewModel, workflow: DomainWorkflow? = nil) {
414 self.viewModel = viewModel
415 self.workflow = workflow
416 _name = State(initialValue: workflow?.name ?? "")
417 _domainsText = State(initialValue: workflow?.domains.joined(separator: "\n") ?? "")
418 _notes = State(initialValue: workflow?.notes ?? "")
419 }
420
421 var body: some View {
422 NavigationStack {
423 Form {
424 Section("Workflow") {
425 TextField("Name", text: $name)
426 TextField("Notes", text: $notes, axis: .vertical)
427 .lineLimit(3...6)
428 }
429
430 Section("Domains") {
431 TextField("example.com\napple.com", text: $domainsText, axis: .vertical)
432 .textInputAutocapitalization(.never)
433 .autocorrectionDisabled()
434 .lineLimit(6...14)
435 }
436 }
437 .navigationTitle(workflow == nil ? "New Workflow" : "Edit Workflow")
438 .toolbar {
439 ToolbarItem(placement: .cancellationAction) {
440 Button("Cancel") {
441 dismiss()
442 }
443 }
444 ToolbarItem(placement: .confirmationAction) {
445 Button("Save") {
446 saveWorkflow()
447 }
448 .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || parsedDomains.isEmpty)
449 }
450 }
451 }
452 .preferredColorScheme(.dark)
453 }
454
455 private var parsedDomains: [String] {
456 domainsText
457 .components(separatedBy: CharacterSet(charactersIn: ",\n"))
458 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
459 .filter { !$0.isEmpty }
460 }
461
462 private func saveWorkflow() {
463 if let workflow {
464 viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes)
465 dismiss()
466 return
467 }
468
469 if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil {
470 dismiss()
471 }
472 }
473}
474
475struct WorkflowRunSummaryView: View {
476 @Bindable var viewModel: DomainViewModel
477 let summary: WorkflowRunSummary
478
479 @State private var showAllResults = false
480
481 private var visibleResults: [BatchLookupResult] {
482 showAllResults ? summary.results : summary.results.filter(\.hasMeaningfulChange)
483 }
484
485 var body: some View {
486 NavigationStack {
487 List {
488 Section("Overview") {
489 statRow(label: "Workflow", value: summary.workflowName)
490 statRow(label: "Processed", value: "\(summary.totalDomains)")
491 statRow(label: "Changed", value: "\(summary.changedDomains)")
492 statRow(label: "Warnings", value: "\(summary.warningDomains)")
493 statRow(label: "Unchanged", value: "\(summary.unchangedDomains)")
494 statRow(label: "Finished", value: summary.generatedAt.formatted(date: .abbreviated, time: .shortened))
495 }
496
497 Section {
498 Toggle("Show unchanged domains", isOn: $showAllResults)
499 }
500
501 if !summary.workflowInsights.isEmpty {
502 Section("Workflow Insights") {
503 ForEach(summary.workflowInsights) { insight in
504 VStack(alignment: .leading, spacing: 4) {
505 Text(insight.description)
506 .font(.system(.callout, design: .monospaced))
507 .foregroundStyle(.primary)
508 Text(insight.domainsInvolved.joined(separator: ", "))
509 .font(.system(.caption, design: .monospaced))
510 .foregroundStyle(.secondary)
511 }
512 }
513 }
514 }
515
516 Section(visibleResults.isEmpty ? "Meaningful Changes" : "Results") {
517 if visibleResults.isEmpty {
518 Text("No domains with meaningful changes or warnings")
519 .font(.system(.caption, design: .monospaced))
520 .foregroundStyle(.secondary)
521 } else {
522 ForEach(visibleResults) { result in
523 if let entry = viewModel.historyEntry(for: result) {
524 NavigationLink {
525 HistoryDetailView(viewModel: viewModel, entry: entry)
526 } label: {
527 BatchResultRowView(result: result)
528 }
529 } else {
530 BatchResultRowView(result: result)
531 }
532 }
533 }
534 }
535 }
536 .navigationTitle("Workflow Summary")
537 .toolbar {
538 ToolbarItemGroup(placement: .topBarTrailing) {
539 Menu {
540 Button("Export TXT") {
541 share(format: .text)
542 }
543 if FeatureAccessService.hasAccess(to: .advancedExports) {
544 Button("Export CSV") {
545 share(format: .csv)
546 }
547 Button("Export JSON") {
548 share(format: .json)
549 }
550 Button("Export Markdown") {
551 share(format: .markdown)
552 }
553 Button("Export PDF") {
554 share(format: .pdf)
555 }
556 } else {
557 Button("CSV Export • Available in Pro") {}
558 .disabled(true)
559 Button("JSON Export • Available in Pro") {}
560 .disabled(true)
561 Button("Markdown Export • Available in Pro") {}
562 .disabled(true)
563 Button("PDF Export • Available in Pro") {}
564 .disabled(true)
565 }
566 } label: {
567 Image(systemName: "square.and.arrow.up")
568 }
569
570 if let workflow = viewModel.workflow(withID: summary.workflowID) {
571 Button {
572 viewModel.runWorkflow(workflow)
573 } label: {
574 Image(systemName: "arrow.clockwise")
575 }
576 .disabled(viewModel.batchLookupRunning)
577 }
578 }
579 }
580 }
581 .preferredColorScheme(.dark)
582 }
583
584 private func statRow(label: String, value: String) -> some View {
585 HStack {
586 Text(label)
587 .font(.system(.caption, design: .monospaced))
588 .foregroundStyle(.secondary)
589 Spacer()
590 Text(value)
591 .font(.system(.callout, design: .monospaced))
592 .foregroundStyle(.primary)
593 }
594 }
595
596 private func share(format: DomainExportFormat) {
597 let changedOnly = !showAllResults
598 let formatter = DateFormatter()
599 formatter.dateFormat = "yyyyMMdd_HHmmss"
600 let timestamp = formatter.string(from: Date())
601 let filename = "\(timestamp)_workflow_summary.\(format.fileExtension)"
602 guard let data = viewModel.exportWorkflowData(summary: summary, changedOnly: changedOnly, format: format) else {
603 return
604 }
605
606 ExportPresenter.share(filename: filename, data: data)
607 }
608}
609
610struct WorkflowBulkAddSheet: View {
611 @Environment(\.dismiss) private var dismiss
612 @Bindable var viewModel: DomainViewModel
613 let title: String
614 let availableDomains: [String]
615
616 @State private var mode: WorkflowDestinationMode = .existing
617 @State private var selectedDomains: Set<String>
618 @State private var selectedWorkflowID: UUID?
619 @State private var newWorkflowName = ""
620 @State private var newWorkflowNotes = ""
621
622 init(viewModel: DomainViewModel, title: String, availableDomains: [String]) {
623 self.viewModel = viewModel
624 self.title = title
625 self.availableDomains = Array(Set(availableDomains)).sorted()
626 _selectedDomains = State(initialValue: Set(availableDomains))
627 _selectedWorkflowID = State(initialValue: viewModel.workflows.first?.id)
628 }
629
630 var body: some View {
631 NavigationStack {
632 List {
633 Section("Selection") {
634 ForEach(availableDomains, id: \.self) { domain in
635 Button {
636 toggle(domain)
637 } label: {
638 HStack {
639 Text(domain)
640 .foregroundStyle(.primary)
641 Spacer()
642 Image(systemName: selectedDomains.contains(domain) ? "checkmark.circle.fill" : "circle")
643 .foregroundStyle(selectedDomains.contains(domain) ? .cyan : .secondary)
644 }
645 }
646 .buttonStyle(.plain)
647 }
648 }
649
650 Section("Destination") {
651 Picker("Destination", selection: $mode) {
652 Text("Existing").tag(WorkflowDestinationMode.existing)
653 Text("New").tag(WorkflowDestinationMode.new)
654 }
655 .pickerStyle(.segmented)
656
657 if mode == .existing, !viewModel.workflows.isEmpty {
658 Picker("Workflow", selection: $selectedWorkflowID) {
659 ForEach(viewModel.workflows) { workflow in
660 Text(workflow.name).tag(Optional(workflow.id))
661 }
662 }
663 } else {
664 TextField("Workflow name", text: $newWorkflowName)
665 TextField("Notes", text: $newWorkflowNotes, axis: .vertical)
666 .lineLimit(2...5)
667 }
668 }
669 }
670 .navigationTitle(title)
671 .toolbar {
672 ToolbarItem(placement: .cancellationAction) {
673 Button("Cancel") {
674 dismiss()
675 }
676 }
677 ToolbarItem(placement: .confirmationAction) {
678 Button("Save") {
679 save()
680 }
681 .disabled(!canSave)
682 }
683 }
684 .onAppear {
685 if viewModel.workflows.isEmpty {
686 mode = .new
687 }
688 }
689 }
690 .preferredColorScheme(.dark)
691 }
692
693 private var selectedDomainList: [String] {
694 availableDomains.filter { selectedDomains.contains($0) }
695 }
696
697 private var canSave: Bool {
698 guard !selectedDomainList.isEmpty else { return false }
699 switch mode {
700 case .existing:
701 return selectedWorkflowID != nil
702 case .new:
703 return !newWorkflowName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
704 }
705 }
706
707 private func toggle(_ domain: String) {
708 if selectedDomains.contains(domain) {
709 selectedDomains.remove(domain)
710 } else {
711 selectedDomains.insert(domain)
712 }
713 }
714
715 private func save() {
716 switch mode {
717 case .existing:
718 guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return }
719 viewModel.addDomains(selectedDomainList, to: workflow)
720 dismiss()
721 case .new:
722 guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else {
723 return
724 }
725 dismiss()
726 }
727 }
728}