krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.8.1: DomainDig/TimelineView.swift · raw
1import SwiftUI
2
3struct TimelineView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6 let domain: String
7
8 @State private var presentedDiff: DomainDiff?
9 @State private var focusedSectionID: String?
10
11 private var timelineSections: [TimelineSection] {
12 viewModel.timelineSections(for: domain)
13 }
14
15 private var compareButtonDisabled: Bool {
16 viewModel.selectedSnapshots.count != 2 && viewModel.historyEntries(for: domain).count < 2
17 }
18
19 var body: some View {
20 List {
21 ForEach(timelineSections) { section in
22 Section(section.title) {
23 ForEach(section.entries) { summary in
24 if let entry = viewModel.historyEntry(withID: summary.historyEntryID) {
25 NavigationLink {
26 HistoryDetailView(viewModel: viewModel, entry: entry)
27 } label: {
28 TimelineRow(summary: summary, entry: entry)
29 }
30 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
31 Button {
32 viewModel.toggleSnapshotSelection(entry)
33 } label: {
34 Label(
35 viewModel.selectedSnapshotIDs.contains(entry.id) ? "Selected" : "Compare",
36 systemImage: viewModel.selectedSnapshotIDs.contains(entry.id) ? "checkmark.circle.fill" : "arrow.left.arrow.right"
37 )
38 }
39
40 Button(role: .destructive) {
41 viewModel.removeHistoryEntries(withIDs: [entry.id])
42 } label: {
43 Label("Delete", systemImage: "trash")
44 }
45 }
46 }
47 }
48 .listRowBackground(Color(.systemGray6).opacity(0.5))
49 }
50 }
51 }
52 .scrollContentBackground(.hidden)
53 .background(Color.black)
54 .navigationTitle(domain)
55 .toolbar {
56 ToolbarItemGroup(placement: .topBarTrailing) {
57 Menu {
58 Picker("Grouping", selection: $viewModel.timelineGrouping) {
59 ForEach(TimelineGroupingOption.allCases) { option in
60 Text(option.title).tag(option)
61 }
62 }
63 } label: {
64 Image(systemName: "line.3.horizontal.decrease.circle")
65 }
66
67 Button("Compare") {
68 if viewModel.selectedSnapshots.count == 2 {
69 presentedDiff = viewModel.generateDiffForSelectedSnapshots()
70 } else {
71 let entries = viewModel.historyEntries(for: domain)
72 guard entries.count >= 2 else { return }
73 presentedDiff = viewModel.generateDiff(from: entries[1], to: entries[0])
74 }
75 focusedSectionID = viewModel.currentDiffTargetSectionID
76 }
77 .disabled(compareButtonDisabled)
78
79 Menu("Export") {
80 Button("Export TXT") {
81 ExportPresenter.share(
82 filename: "\(domain)-timeline.txt",
83 contents: viewModel.exportTimelineText(domain: domain, includeDiffSummary: true)
84 )
85 }
86
87 Button("Export JSON") {
88 guard let data = viewModel.exportTimelineJSONData(domain: domain, includeDiffSummary: true) else { return }
89 ExportPresenter.share(filename: "\(domain)-timeline.json", data: data)
90 }
91 }
92 }
93 }
94 .sheet(item: $presentedDiff) { diff in
95 NavigationStack {
96 TimelineDiffView(viewModel: viewModel, diff: diff, focusedSectionID: $focusedSectionID)
97 }
98 }
99 }
100}
101
102private struct TimelineRow: View {
103 @Environment(\.appDensity) private var appDensity
104 let summary: SnapshotSummary
105 let entry: HistoryEntry
106
107 var body: some View {
108 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
109 HStack(alignment: .center, spacing: 8) {
110 Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
111 .font(appDensity.font(.callout))
112 .foregroundStyle(.primary)
113 Spacer()
114 if let severity = summary.severitySummary {
115 AppStatusBadgeView(
116 model: .init(
117 title: severity.title,
118 systemImage: "arrow.triangle.2.circlepath",
119 foregroundColor: severity == .high ? .red : .yellow,
120 backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16)
121 )
122 )
123 }
124 }
125
126 Text(summary.changeSummaryMessage ?? "No change summary")
127 .font(appDensity.font(.caption))
128 .foregroundStyle(.secondary)
129 .lineLimit(2)
130
131 HStack(spacing: 8) {
132 AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability))
133 if let riskScore = summary.riskScore {
134 Text("Risk \(riskScore)")
135 .lineLimit(1)
136 .minimumScaleFactor(0.85)
137 }
138 }
139 .font(appDensity.font(.caption2))
140 .foregroundStyle(.secondary)
141
142 if !entry.intelligenceTimeline.isEmpty {
143 VStack(alignment: .leading, spacing: 4) {
144 ForEach(Array(entry.intelligenceTimeline.prefix(2))) { event in
145 Text("\(event.title): \(event.detail)")
146 .font(appDensity.font(.caption2))
147 .foregroundStyle(.secondary)
148 .lineLimit(1)
149 }
150 }
151 }
152
153 HStack(spacing: 8) {
154 if let primaryIP = summary.primaryIP {
155 Text(primaryIP)
156 .lineLimit(1)
157 .truncationMode(.middle)
158 }
159 Spacer(minLength: 8)
160 Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
161 .lineLimit(1)
162 }
163 .font(appDensity.font(.caption2))
164 .foregroundStyle(.secondary)
165 }
166 }
167}
168
169struct TimelineDiffView: View {
170 @Bindable var viewModel: DomainViewModel
171 let diff: DomainDiff
172 @Binding var focusedSectionID: String?
173
174 var body: some View {
175 ScrollViewReader { proxy in
176 ScrollView {
177 VStack(alignment: .leading, spacing: 12) {
178 HStack {
179 Button("Previous Change") {
180 viewModel.moveToPreviousDiffChange()
181 focusedSectionID = viewModel.currentDiffTargetSectionID
182 scroll(proxy: proxy)
183 }
184 .disabled(viewModel.activeDiffChangeIndex == 0)
185
186 Button("Next Change") {
187 viewModel.moveToNextDiffChange()
188 focusedSectionID = viewModel.currentDiffTargetSectionID
189 scroll(proxy: proxy)
190 }
191 .disabled(viewModel.activeDomainDiff?.changedSectionIDs.isEmpty != false || viewModel.currentDiffTargetSectionID == viewModel.activeDomainDiff?.changedSectionIDs.last)
192
193 Spacer()
194 }
195
196 DomainDiffView(
197 title: "Snapshot Diff",
198 sections: diff.sections,
199 contextNote: diff.contextNote,
200 showsUnchanged: false,
201 highlightedSectionID: focusedSectionID
202 )
203 }
204 .padding()
205 }
206 .background(Color.black)
207 .navigationTitle("Compare Snapshots")
208 .navigationBarTitleDisplayMode(.inline)
209 .onAppear {
210 scroll(proxy: proxy)
211 }
212 .onChange(of: focusedSectionID) { _, _ in
213 scroll(proxy: proxy)
214 }
215 }
216 }
217
218 private func scroll(proxy: ScrollViewProxy) {
219 guard let focusedSectionID else { return }
220 withAnimation {
221 proxy.scrollTo(focusedSectionID, anchor: .top)
222 }
223 }
224}