summaryrefslogtreecommitdiff
path: root/DomainDig/TimelineView.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-24 12:38:38 -0500
committerChristian Cleberg <[email protected]>2026-04-24 12:38:38 -0500
commitd0b3b7a15b59266b4ae4262fdb0364245092c17c (patch)
tree5adb0df9963caf78aa98fa17adfb2c175291ded9 /DomainDig/TimelineView.swift
parentb35f1b432fc24fbab1fa05593c9a0bca7e4d95a6 (diff)
downloaddomain-dig-d0b3b7a15b59266b4ae4262fdb0364245092c17c.tar.gz
domain-dig-d0b3b7a15b59266b4ae4262fdb0364245092c17c.tar.bz2
domain-dig-d0b3b7a15b59266b4ae4262fdb0364245092c17c.zip
feat(v3.7.0): add timeline and advanced diffing system
- introduce TimelineView for historical snapshots - allow comparison between any two snapshots - implement DiffService for structured domain diffs - improve diff visualization with clear change indicators - add navigation across changes - optimize history loading for performance - extend CLI and export to support timeline data
Diffstat (limited to 'DomainDig/TimelineView.swift')
-rw-r--r--DomainDig/TimelineView.swift224
1 files changed, 224 insertions, 0 deletions
diff --git a/DomainDig/TimelineView.swift b/DomainDig/TimelineView.swift
new file mode 100644
index 0000000..404fc1e
--- /dev/null
+++ b/DomainDig/TimelineView.swift
@@ -0,0 +1,224 @@
+import SwiftUI
+
+struct TimelineView: View {
+ @Environment(\.appDensity) private var appDensity
+ @Bindable var viewModel: DomainViewModel
+ let domain: String
+
+ @State private var presentedDiff: DomainDiff?
+ @State private var focusedSectionID: String?
+
+ private var timelineSections: [TimelineSection] {
+ viewModel.timelineSections(for: domain)
+ }
+
+ var body: some View {
+ List {
+ ForEach(timelineSections) { section in
+ Section(section.title) {
+ ForEach(section.entries) { summary in
+ if let entry = viewModel.historyEntry(withID: summary.historyEntryID) {
+ NavigationLink {
+ HistoryDetailView(viewModel: viewModel, entry: entry)
+ } label: {
+ TimelineRow(summary: summary)
+ }
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ Button {
+ viewModel.toggleSnapshotSelection(entry)
+ } label: {
+ Label(
+ viewModel.selectedSnapshotIDs.contains(entry.id) ? "Selected" : "Compare",
+ systemImage: viewModel.selectedSnapshotIDs.contains(entry.id) ? "checkmark.circle.fill" : "arrow.left.arrow.right"
+ )
+ }
+
+ Button(role: .destructive) {
+ viewModel.removeHistoryEntries(withIDs: [entry.id])
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ }
+ }
+ }
+ .listRowBackground(Color(.systemGray6).opacity(0.5))
+ }
+ }
+ }
+ .scrollContentBackground(.hidden)
+ .background(Color.black)
+ .navigationTitle(domain)
+ .toolbar {
+ ToolbarItemGroup(placement: .topBarTrailing) {
+ Menu {
+ Picker("Grouping", selection: $viewModel.timelineGrouping) {
+ ForEach(TimelineGroupingOption.allCases) { option in
+ Text(option.title).tag(option)
+ }
+ }
+ } label: {
+ Image(systemName: "line.3.horizontal.decrease.circle")
+ }
+
+ Button("Compare") {
+ presentedDiff = viewModel.generateDiffForSelectedSnapshots()
+ focusedSectionID = viewModel.currentDiffTargetSectionID
+ }
+ .disabled(viewModel.selectedSnapshots.count != 2)
+
+ Menu("Export") {
+ Button("Export TXT") {
+ ExportPresenter.share(
+ filename: "\(domain)-timeline.txt",
+ contents: viewModel.exportTimelineText(domain: domain, includeDiffSummary: true)
+ )
+ }
+
+ Button("Export JSON") {
+ guard let data = viewModel.exportTimelineJSONData(domain: domain, includeDiffSummary: true) else { return }
+ ExportPresenter.share(filename: "\(domain)-timeline.json", data: data)
+ }
+ }
+ }
+ }
+ .sheet(item: $presentedDiff) { diff in
+ NavigationStack {
+ TimelineDiffView(viewModel: viewModel, diff: diff, focusedSectionID: $focusedSectionID)
+ }
+ }
+ }
+}
+
+private struct TimelineRow: View {
+ @Environment(\.appDensity) private var appDensity
+ let summary: SnapshotSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
+ HStack(alignment: .center, spacing: 8) {
+ Text(summary.timestamp.formatted(date: .abbreviated, time: .shortened))
+ .font(appDensity.font(.callout))
+ .foregroundStyle(.primary)
+ Spacer()
+ if let severity = summary.severitySummary {
+ AppStatusBadgeView(
+ model: .init(
+ title: severity.title,
+ systemImage: "arrow.triangle.2.circlepath",
+ foregroundColor: severity == .high ? .red : .yellow,
+ backgroundColor: (severity == .high ? Color.red : .yellow).opacity(0.16)
+ )
+ )
+ }
+ }
+
+ Text(summary.changeSummaryMessage ?? "No change summary")
+ .font(appDensity.font(.caption))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ HStack(spacing: 8) {
+ AppStatusBadgeView(model: AppStatusFactory.availability(summary.availability))
+ if let primaryIP = summary.primaryIP {
+ AppStatusBadgeView(
+ model: .init(
+ title: primaryIP,
+ systemImage: "network",
+ foregroundColor: .blue,
+ backgroundColor: .blue.opacity(0.16)
+ )
+ )
+ }
+ if let tlsStatus = summary.tlsStatus {
+ AppStatusBadgeView(
+ model: .init(
+ title: tlsStatus.capitalized,
+ systemImage: "lock.shield",
+ foregroundColor: .green,
+ backgroundColor: .green.opacity(0.16)
+ )
+ )
+ }
+ if let riskScore = summary.riskScore {
+ AppStatusBadgeView(
+ model: .init(
+ title: "Risk \(riskScore)",
+ systemImage: "exclamationmark.shield",
+ foregroundColor: riskScore >= 70 ? .red : .orange,
+ backgroundColor: (riskScore >= 70 ? Color.red : .orange).opacity(0.16)
+ )
+ )
+ }
+ }
+
+ HStack(spacing: 8) {
+ if let snapshotIndex = summary.snapshotIndex {
+ Text("#\(snapshotIndex)")
+ }
+ Text(summary.timestamp.formatted(.relative(presentation: .named)))
+ if summary.changeCount > 0 {
+ Text("\(summary.changeCount) changes")
+ }
+ }
+ .font(appDensity.font(.caption2))
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+struct TimelineDiffView: View {
+ @Bindable var viewModel: DomainViewModel
+ let diff: DomainDiff
+ @Binding var focusedSectionID: String?
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Button("Previous Change") {
+ viewModel.moveToPreviousDiffChange()
+ focusedSectionID = viewModel.currentDiffTargetSectionID
+ scroll(proxy: proxy)
+ }
+ .disabled(viewModel.activeDiffChangeIndex == 0)
+
+ Button("Next Change") {
+ viewModel.moveToNextDiffChange()
+ focusedSectionID = viewModel.currentDiffTargetSectionID
+ scroll(proxy: proxy)
+ }
+ .disabled(viewModel.activeDomainDiff?.changedSectionIDs.isEmpty != false || viewModel.currentDiffTargetSectionID == viewModel.activeDomainDiff?.changedSectionIDs.last)
+
+ Spacer()
+ }
+
+ DomainDiffView(
+ title: "Snapshot Diff",
+ sections: diff.sections,
+ contextNote: diff.contextNote,
+ showsUnchanged: false,
+ highlightedSectionID: focusedSectionID
+ )
+ }
+ .padding()
+ }
+ .background(Color.black)
+ .navigationTitle("Compare Snapshots")
+ .navigationBarTitleDisplayMode(.inline)
+ .onAppear {
+ scroll(proxy: proxy)
+ }
+ .onChange(of: focusedSectionID) { _, _ in
+ scroll(proxy: proxy)
+ }
+ }
+ }
+
+ private func scroll(proxy: ScrollViewProxy) {
+ guard let focusedSectionID else { return }
+ withAnimation {
+ proxy.scrollTo(focusedSectionID, anchor: .top)
+ }
+ }
+}