krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v4.4.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}