Commit d624802338
Unsigned
Layout: unified · split
DomainDig/DiffService.swift +51
| @@ -105,6 +105,32 @@ struct DomainDiff: Identifiable, Equatable, Codable { | |||
| 105 | typealias DomainDiffItem = DiffItem | 105 | typealias DomainDiffItem = DiffItem |
| 106 | typealias DomainDiffSection = DiffSection | 106 | typealias DomainDiffSection = DiffSection |
| 107 | 107 | ||
| 108 | /// Result of comparing two distinct domains' latest reports side by side, as | ||
| 109 | /// opposed to `DomainDiff`, which compares the same domain across time. | ||
| 110 | struct DomainComparisonResult: Identifiable, Equatable { | ||
| 111 | let domainA: String | ||
| 112 | let domainB: String | ||
| 113 | let generatedAt: Date | ||
| 114 | let sections: [DiffSection] | ||
| 115 | let contextNote: String? | ||
| 116 | |||
| 117 | var id: String { | ||
| 118 | "\(domainA)-\(domainB)-\(generatedAt.timeIntervalSince1970)" | ||
| 119 | } | ||
| 120 | |||
| 121 | var changedSections: [DiffSection] { | ||
| 122 | sections.filter(\.hasChanges) | ||
| 123 | } | ||
| 124 | |||
| 125 | var changeCount: Int { | ||
| 126 | sections.reduce(0) { $0 + $1.changeCount } | ||
| 127 | } | ||
| 128 | |||
| 129 | var severity: ChangeSeverity { | ||
| 130 | sections.map(\.severity).max() ?? .low | ||
| 131 | } | ||
| 132 | } | ||
| 133 | |||
| 108 | enum DiffService { | 134 | enum DiffService { |
| 109 | static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff { | 135 | static func compare(from oldReport: DomainReport, to newReport: DomainReport) -> DomainDiff { |
| 110 | let sections = [ | 136 | let sections = [ |
| @@ -181,6 +207,31 @@ enum DiffService { | |||
| 181 | ) | 207 | ) |
| 182 | } | 208 | } |
| 183 | 209 | ||
| 210 | /// Compares two distinct domains' reports section by section. Reuses the same | ||
| 211 | /// field-level diff logic as time-based comparison; the two reports are simply | ||
| 212 | /// unrelated domains rather than the same domain at different times. | ||
| 213 | static func compare(domainA: DomainReport, domainB: DomainReport) -> DomainComparisonResult { | ||
| 214 | let sections = [ | ||
| 215 | availabilitySection(from: domainA, to: domainB), | ||
| 216 | ownershipSection(from: domainA, to: domainB), | ||
| 217 | dnsSection(from: domainA, to: domainB), | ||
| 218 | webSection(from: domainA, to: domainB), | ||
| 219 | emailSection(from: domainA, to: domainB), | ||
| 220 | networkSection(from: domainA, to: domainB), | ||
| 221 | subdomainsSection(from: domainA, to: domainB), | ||
| 222 | intelligenceSection(from: domainA, to: domainB), | ||
| 223 | riskSection(from: domainA, to: domainB) | ||
| 224 | ] | ||
| 225 | |||
| 226 | return DomainComparisonResult( | ||
| 227 | domainA: domainA.domain, | ||
| 228 | domainB: domainB.domain, | ||
| 229 | generatedAt: Date(), | ||
| 230 | sections: sections, | ||
| 231 | contextNote: comparisonContextNote(from: domainA, to: domainB) | ||
| 232 | ) | ||
| 233 | } | ||
| 234 | |||
| 184 | static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? { | 235 | static func comparisonContextNote(from oldReport: DomainReport, to newReport: DomainReport) -> String? { |
| 185 | var notes: [String] = [] | 236 | var notes: [String] = [] |
| 186 | if oldReport.resolverURLString != newReport.resolverURLString { | 237 | if oldReport.resolverURLString != newReport.resolverURLString { |
DomainDig/DomainCompareView.swift added +82
| @@ -0,0 +1,82 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// Side-by-side comparison of two tracked domains' latest reports, reusing the | ||
| 4 | /// same section-diff rendering (`DomainDiffView`) that time-based history diffs | ||
| 5 | /// use, via `DiffService.compare(domainA:domainB:)`. | ||
| 6 | struct DomainCompareView: View { | ||
| 7 | @Bindable var viewModel: DomainViewModel | ||
| 8 | @State private var domainAID: UUID? | ||
| 9 | @State private var domainBID: UUID? | ||
| 10 | |||
| 11 | private var states: [PortfolioDomainStatus] { | ||
| 12 | viewModel.portfolioDashboardData.domainStates | ||
| 13 | } | ||
| 14 | |||
| 15 | private var stateA: PortfolioDomainStatus? { | ||
| 16 | states.first { $0.trackedDomain.id == domainAID } | ||
| 17 | } | ||
| 18 | |||
| 19 | private var stateB: PortfolioDomainStatus? { | ||
| 20 | states.first { $0.trackedDomain.id == domainBID } | ||
| 21 | } | ||
| 22 | |||
| 23 | private var result: DomainComparisonResult? { | ||
| 24 | guard let stateA, let stateB, stateA.trackedDomain.id != stateB.trackedDomain.id else { | ||
| 25 | return nil | ||
| 26 | } | ||
| 27 | return DiffService.compare(domainA: stateA.report, domainB: stateB.report) | ||
| 28 | } | ||
| 29 | |||
| 30 | var body: some View { | ||
| 31 | List { | ||
| 32 | Section("Domains") { | ||
| 33 | domainPicker("Domain A", selection: $domainAID) | ||
| 34 | domainPicker("Domain B", selection: $domainBID) | ||
| 35 | } | ||
| 36 | |||
| 37 | if domainAID != nil, domainAID == domainBID { | ||
| 38 | Section { | ||
| 39 | Text("Choose two different domains to compare.") | ||
| 40 | .font(.callout) | ||
| 41 | .foregroundStyle(.secondary) | ||
| 42 | } | ||
| 43 | } | ||
| 44 | |||
| 45 | if let result { | ||
| 46 | Section { | ||
| 47 | DomainDiffView( | ||
| 48 | title: "\(result.domainA) vs \(result.domainB)", | ||
| 49 | sections: result.sections, | ||
| 50 | contextNote: result.contextNote, | ||
| 51 | showsUnchanged: true, | ||
| 52 | highlightedSectionID: nil | ||
| 53 | ) | ||
| 54 | } | ||
| 55 | .listRowBackground(Color.clear) | ||
| 56 | } else if domainAID == nil || domainBID == nil { | ||
| 57 | Section { | ||
| 58 | EmptyStateCardView( | ||
| 59 | title: "Pick Two Domains", | ||
| 60 | message: "Select a domain in each slot to see a side-by-side comparison across DNS, TLS, ownership, and risk.", | ||
| 61 | suggestion: "Both domains must already be tracked.", | ||
| 62 | systemImage: "arrow.left.arrow.right", | ||
| 63 | showsCardBackground: false | ||
| 64 | ) | ||
| 65 | } | ||
| 66 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | .scrollContentBackground(.hidden) | ||
| 70 | .background(Color.black) | ||
| 71 | .navigationTitle("Compare Domains") | ||
| 72 | } | ||
| 73 | |||
| 74 | private func domainPicker(_ label: String, selection: Binding<UUID?>) -> some View { | ||
| 75 | Picker(label, selection: selection) { | ||
| 76 | Text("Select a domain").tag(UUID?.none) | ||
| 77 | ForEach(states) { state in | ||
| 78 | Text(state.trackedDomain.domain).tag(Optional(state.trackedDomain.id)) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | } | ||
| 82 | } | ||
DomainDig/WatchlistView.swift +6
| @@ -120,6 +120,12 @@ struct WatchlistView: View { | |||
| 120 | showWorkflowAddSheet = true | 120 | showWorkflowAddSheet = true |
| 121 | } | 121 | } |
| 122 | 122 | ||
| 123 | if viewModel.trackedDomains.count >= 2 { | ||
| 124 | NavigationLink("Compare Domains") { | ||
| 125 | DomainCompareView(viewModel: viewModel) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 123 | Button("Export TXT") { | 129 | Button("Export TXT") { |
| 124 | shareTrackedDomains(format: .text) | 130 | shareTrackedDomains(format: .text) |
| 125 | } | 131 | } |