krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.5.0: DomainDig/WatchlistView.swift · raw
1import SwiftUI
2
3struct WatchlistView: View {
4 @Environment(\.appDensity) private var appDensity
5 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss
7 @State private var purchaseService = PurchaseService.shared
8 @State private var showWorkflowAddSheet = false
9 @State private var showAddDomainSheet = false
10 @State private var newTrackedDomain = ""
11 @State private var addDomainError: String?
12 @FocusState private var isAddDomainFieldFocused: Bool
13
14 private var pinnedDomains: [TrackedDomain] {
15 viewModel.filteredTrackedDomains.filter(\.isPinned)
16 }
17
18 private var otherDomains: [TrackedDomain] {
19 viewModel.filteredTrackedDomains.filter { !$0.isPinned }
20 }
21
22 var body: some View {
23 let _ = purchaseService.currentTier
24
25 List {
26 if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) {
27 Section("Refresh Progress") {
28 VStack(alignment: .leading, spacing: 8) {
29 ProgressView(value: Double(viewModel.batchCompletedCount), total: Double(max(viewModel.batchTotalCount, 1)))
30 .tint(.cyan)
31 HStack {
32 Text(viewModel.batchProgressLabel)
33 .font(appDensity.font(.caption))
34 .foregroundStyle(.secondary)
35 Spacer()
36 if viewModel.batchLookupRunning {
37 Button("Cancel") {
38 viewModel.cancelBatchLookup()
39 }
40 .buttonStyle(.bordered)
41 .font(appDensity.font(.caption2))
42 }
43 }
44
45 ForEach(viewModel.batchResults.prefix(5)) { result in
46 BatchResultRowView(result: result)
47 }
48 }
49 .padding(.vertical, 4)
50 }
51 .listRowBackground(Color(.systemGray6).opacity(0.5))
52 }
53
54 if viewModel.filteredTrackedDomains.isEmpty {
55 Section {
56 EmptyStateCardView(
57 title: "No Tracked Domains",
58 message: "Track important domains locally so you can refresh them quickly and see status changes at a glance.",
59 suggestion: "Run an inspection and use the Track action on a domain you care about.",
60 systemImage: "eye",
61 showsCardBackground: false
62 )
63 }
64 .listRowBackground(Color(.systemGray6).opacity(0.5))
65 } else {
66 if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) {
67 Section {
68 Text(limitMessage)
69 .font(appDensity.font(.caption))
70 .foregroundStyle(.secondary)
71 }
72 .listRowBackground(Color(.systemGray6).opacity(0.5))
73 }
74
75 if !pinnedDomains.isEmpty {
76 trackedSection(title: "Pinned", domains: pinnedDomains)
77 }
78
79 if !otherDomains.isEmpty {
80 trackedSection(title: pinnedDomains.isEmpty ? "Tracked Domains" : "Others", domains: otherDomains)
81 }
82 }
83 }
84 .animation(.easeInOut(duration: 0.2), value: viewModel.filteredTrackedDomains.map(\.id))
85 .scrollContentBackground(.hidden)
86 .background(Color.black)
87 .navigationTitle("Watchlist")
88 .searchable(text: $viewModel.watchlistSearchText, prompt: "Search tracked domains")
89 .toolbar {
90 ToolbarItemGroup(placement: .topBarTrailing) {
91 Button {
92 addDomainError = nil
93 newTrackedDomain = ""
94 showAddDomainSheet = true
95 } label: {
96 Image(systemName: "plus")
97 }
98
99 if !viewModel.filteredTrackedDomains.isEmpty {
100 Menu {
101 Picker("Filter", selection: $viewModel.watchlistFilter) {
102 ForEach(WatchlistFilterOption.allCases) { option in
103 Text(option.title).tag(option)
104 }
105 }
106
107 Picker("Sort", selection: $viewModel.watchlistSortOption) {
108 ForEach(WatchlistSortOption.allCases) { option in
109 Text(option.title).tag(option)
110 }
111 }
112
113 Button(viewModel.batchLookupRunning ? "Check All Running" : "Check All") {
114 AppHaptics.refresh()
115 viewModel.refreshAllTrackedDomains()
116 }
117 .disabled(viewModel.batchLookupRunning)
118
119 Button("Add to Workflow") {
120 showWorkflowAddSheet = true
121 }
122
123 Button("Export TXT") {
124 shareTrackedDomains(format: .text)
125 }
126
127 if FeatureAccessService.hasAccess(to: .advancedExports) {
128 Button("Export CSV") {
129 shareTrackedDomains(format: .csv)
130 }
131
132 Button("Export JSON") {
133 shareTrackedDomains(format: .json)
134 }
135 } else {
136 Button("CSV Export • Available in Pro") {}
137 .disabled(true)
138 Button("JSON Export • Available in Pro") {}
139 .disabled(true)
140 }
141 } label: {
142 Image(systemName: "line.3.horizontal.decrease.circle")
143 }
144
145 EditButton()
146 }
147 }
148 }
149 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
150 dismiss()
151 }
152 .sheet(item: batchSummaryBinding) { summary in
153 BatchSweepSummaryView(viewModel: viewModel, summary: summary)
154 }
155 .sheet(isPresented: $showAddDomainSheet) {
156 NavigationStack {
157 Form {
158 Section("Domain") {
159 TextField("example.com", text: $newTrackedDomain)
160 .textInputAutocapitalization(.never)
161 .autocorrectionDisabled()
162 .keyboardType(.URL)
163 .textContentType(.URL)
164 .focused($isAddDomainFieldFocused)
165 .onSubmit(addTrackedDomain)
166 }
167
168 Section {
169 Text("Adds the domain directly to your watchlist so monitoring can run without a prior inspection.")
170 .font(appDensity.font(.caption))
171 .foregroundStyle(.secondary)
172 }
173
174 if let addDomainError {
175 Section {
176 Text(addDomainError)
177 .font(appDensity.font(.caption))
178 .foregroundStyle(.red)
179 }
180 }
181 }
182 .navigationTitle("Add Domain")
183 .toolbar {
184 ToolbarItem(placement: .cancellationAction) {
185 Button("Cancel") {
186 showAddDomainSheet = false
187 }
188 }
189
190 ToolbarItem(placement: .confirmationAction) {
191 Button("Add", action: addTrackedDomain)
192 }
193 }
194 .onAppear {
195 DispatchQueue.main.async {
196 isAddDomainFieldFocused = true
197 }
198 }
199 }
200 }
201 .sheet(isPresented: $showWorkflowAddSheet) {
202 WorkflowBulkAddSheet(
203 viewModel: viewModel,
204 title: "Add Watchlist Domains",
205 availableDomains: viewModel.filteredTrackedDomains.map(\.domain)
206 )
207 }
208 .preferredColorScheme(.dark)
209 }
210
211 @ViewBuilder
212 private func trackedSection(title: String, domains: [TrackedDomain]) -> some View {
213 Section(title) {
214 ForEach(domains) { trackedDomain in
215 trackedDomainRow(trackedDomain)
216 }
217 }
218 }
219
220 private func trackedDomainRow(_ trackedDomain: TrackedDomain) -> some View {
221 NavigationLink {
222 TrackedDomainDetailView(viewModel: viewModel, trackedDomain: trackedDomain)
223 } label: {
224 WatchlistRowView(
225 trackedDomain: trackedDomain,
226 isRefreshing: viewModel.refreshingTrackedDomainID == trackedDomain.id
227 )
228 }
229 .buttonStyle(.plain)
230 .swipeActions(edge: .leading, allowsFullSwipe: false) {
231 Button {
232 AppHaptics.refresh()
233 viewModel.refreshTrackedDomain(trackedDomain)
234 } label: {
235 Label("Refresh", systemImage: "arrow.clockwise")
236 }
237 .tint(.cyan)
238
239 Button {
240 viewModel.togglePinned(for: trackedDomain)
241 } label: {
242 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
243 }
244 .tint(.yellow)
245 }
246 .swipeActions(edge: .trailing, allowsFullSwipe: false) {
247 Button {
248 AppHaptics.refresh()
249 viewModel.refreshTrackedDomain(trackedDomain)
250 } label: {
251 Label("Refresh", systemImage: "arrow.clockwise")
252 }
253 .tint(.cyan)
254
255 if viewModel.canDelete(trackedDomain) {
256 Button(role: .destructive) {
257 viewModel.deleteTrackedDomain(trackedDomain)
258 } label: {
259 Label("Delete", systemImage: "trash")
260 }
261 }
262 }
263 .contextMenu {
264 Button {
265 AppHaptics.refresh()
266 viewModel.refreshTrackedDomain(trackedDomain)
267 } label: {
268 Label("Refresh", systemImage: "arrow.clockwise")
269 }
270
271 Button {
272 dismiss()
273 viewModel.rerunInspection(for: trackedDomain)
274 } label: {
275 Label("Open Inspection", systemImage: "magnifyingglass")
276 }
277
278 Button {
279 viewModel.togglePinned(for: trackedDomain)
280 } label: {
281 Label(trackedDomain.isPinned ? "Unpin" : "Pin", systemImage: trackedDomain.isPinned ? "pin.slash" : "pin")
282 }
283 .disabled(!viewModel.canEdit(trackedDomain))
284
285 Button {
286 // The system sharing UI manages participants and permissions.
287 } label: {
288 Label(trackedDomain.collaboration?.isShared == true ? "Shared" : "Private", systemImage: "person.2")
289 }
290 .disabled(true)
291
292 if viewModel.canDelete(trackedDomain) {
293 Button(role: .destructive) {
294 viewModel.deleteTrackedDomain(trackedDomain)
295 } label: {
296 Label("Delete", systemImage: "trash")
297 }
298 }
299 }
300 .listRowBackground(Color(.systemGray6).opacity(0.5))
301 }
302
303 private var batchSummaryBinding: Binding<BatchSweepSummary?> {
304 Binding(
305 get: { viewModel.latestBatchSweepSummary },
306 set: { viewModel.latestBatchSweepSummary = $0 }
307 )
308 }
309
310 private func deleteFilteredTrackedDomains(at offsets: IndexSet) {
311 let domains = offsets.map { viewModel.filteredTrackedDomains[$0] }
312 domains.forEach(viewModel.deleteTrackedDomain)
313 }
314
315 private func shareTrackedDomains(format: DomainExportFormat) {
316 let formatter = DateFormatter()
317 formatter.dateFormat = "yyyyMMdd_HHmmss"
318 let timestamp = formatter.string(from: Date())
319 let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
320 let data: Data
321
322 switch format {
323 case .text:
324 data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8)
325 case .csv:
326 data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8)
327 case .json:
328 data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8)
329 }
330
331 ExportPresenter.share(filename: filename, data: data)
332 }
333
334 private func addTrackedDomain() {
335 let draft = newTrackedDomain.trimmingCharacters(in: .whitespacesAndNewlines)
336 guard !draft.isEmpty else {
337 addDomainError = "Enter a domain to add."
338 return
339 }
340
341 if viewModel.trackDomain(domain: draft, availabilityStatus: nil) {
342 AppHaptics.track()
343 isAddDomainFieldFocused = false
344 addDomainError = nil
345 newTrackedDomain = ""
346 showAddDomainSheet = false
347 } else if viewModel.upgradePrompt == nil {
348 addDomainError = "Enter a valid domain like example.com."
349 }
350 }
351}
352
353struct WatchlistRowView: View {
354 @Environment(\.appDensity) private var appDensity
355 let trackedDomain: TrackedDomain
356 let isRefreshing: Bool
357
358 var body: some View {
359 VStack(alignment: .leading, spacing: appDensity.metrics.rowSpacing + 1) {
360 HStack(alignment: .firstTextBaseline, spacing: 8) {
361 if trackedDomain.isPinned {
362 Image(systemName: "pin.fill")
363 .font(.caption2)
364 .foregroundStyle(.yellow)
365 }
366 Text(trackedDomain.domain)
367 .font(appDensity.font(.callout))
368 .foregroundStyle(.primary)
369 .lineLimit(2)
370 .multilineTextAlignment(.leading)
371 Spacer(minLength: 8)
372 statusBadge
373 }
374
375 Text("Updated \(trackedDomain.updatedAt.formatted(date: .abbreviated, time: .shortened))")
376 .font(appDensity.font(.caption2))
377 .foregroundStyle(.secondary)
378
379 if let collaboration = trackedDomain.collaboration, collaboration.isShared {
380 Text("\(collaboration.ownership.title) • \(collaboration.permission.title)")
381 .font(appDensity.font(.caption2))
382 .foregroundStyle(.secondary)
383 }
384
385 HStack(spacing: 8) {
386 Text(trackedDomain.monitoringEnabled ? "Monitoring on" : "Monitoring off")
387 if let lastMonitoredAt = trackedDomain.lastMonitoredAt {
388 Text("Checked \(lastMonitoredAt.formatted(date: .omitted, time: .shortened))")
389 }
390 if let lastAlertAt = trackedDomain.lastAlertAt {
391 Text("Alert \(lastAlertAt.formatted(date: .omitted, time: .shortened))")
392 }
393 }
394 .font(appDensity.font(.caption2))
395 .foregroundStyle(.secondary)
396
397 indicatorRow
398
399 if let note = trackedDomain.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty {
400 Text(note)
401 .font(appDensity.font(.caption))
402 .foregroundStyle(.secondary)
403 .lineLimit(2)
404 } else if let summary = trackedDomain.lastChangeSummary {
405 Text(summary.message)
406 .font(appDensity.font(.caption))
407 .foregroundStyle(.secondary)
408 .lineLimit(2)
409 }
410 }
411 .frame(maxWidth: .infinity, alignment: .leading)
412 .padding(.vertical, 4)
413 }
414
415 private func availabilityLabel(_ status: DomainAvailabilityStatus?) -> String {
416 switch status {
417 case .available:
418 return "Available"
419 case .registered:
420 return "Registered"
421 case .unknown, .none:
422 return "Unknown"
423 }
424 }
425
426 @ViewBuilder
427 private var statusBadge: some View {
428 if isRefreshing {
429 AppStatusBadgeView(model: .init(title: "Refreshing", systemImage: "arrow.clockwise", foregroundColor: .secondary, backgroundColor: Color(.systemGray5).opacity(0.6)))
430 } else {
431 AppStatusBadgeView(model: AppStatusFactory.availability(trackedDomain.lastKnownAvailability))
432 }
433 }
434
435 @ViewBuilder
436 private var indicatorRow: some View {
437 HStack(spacing: 8) {
438 if trackedDomain.collaboration?.isShared == true {
439 AppStatusBadgeView(
440 model: .init(
441 title: "Shared",
442 systemImage: "person.2.fill",
443 foregroundColor: .cyan,
444 backgroundColor: .cyan.opacity(0.16)
445 )
446 )
447 }
448
449 AppStatusBadgeView(model: AppStatusFactory.change(trackedDomain.lastChangeSummary))
450
451 if trackedDomain.certificateWarningLevel != .none {
452 AppStatusBadgeView(model: certificateBadge)
453 }
454 }
455 }
456
457 private var certificateBadge: AppStatusBadgeModel {
458 let days = trackedDomain.certificateDaysRemaining.map { "\($0)d" } ?? "Soon"
459 switch trackedDomain.certificateWarningLevel {
460 case .critical:
461 return .init(title: "Invalid \(days)", systemImage: "xmark.octagon.fill", foregroundColor: .red, backgroundColor: .red.opacity(0.16))
462 case .warning:
463 return .init(title: "Expiring \(days)", systemImage: "exclamationmark.triangle.fill", foregroundColor: .yellow, backgroundColor: .yellow.opacity(0.16))
464 case .none:
465 return .init(title: "Valid", systemImage: "lock.fill", foregroundColor: .green, backgroundColor: .green.opacity(0.16))
466 }
467 }
468}
469
470struct TrackedDomainDetailView: View {
471 @Bindable var viewModel: DomainViewModel
472 let trackedDomain: TrackedDomain
473 @Environment(\.dismiss) private var dismiss
474
475 @State private var noteDraft = ""
476 @State private var isEditingNote = false
477 @State private var showRerunOptions = false
478 @State private var shareEntity: ShareableEntity?
479 @State private var showingAuditTimeline = false
480 @State private var auditStartInFlight = false
481
482 private var liveTrackedDomain: TrackedDomain {
483 viewModel.trackedDomains.first(where: { $0.id == trackedDomain.id }) ?? trackedDomain
484 }
485
486 private var latestSnapshots: [HistoryEntry] {
487 viewModel.recentSnapshots(for: liveTrackedDomain)
488 }
489
490 private var latestDiffSections: [DomainDiffSection] {
491 viewModel.diffSectionsForLatestSnapshots(of: liveTrackedDomain)
492 }
493
494 var body: some View {
495 List {
496 Section {
497 WatchlistRowView(
498 trackedDomain: liveTrackedDomain,
499 isRefreshing: viewModel.refreshingTrackedDomainID == liveTrackedDomain.id
500 )
501 }
502 .listRowBackground(Color(.systemGray6).opacity(0.5))
503
504 Section {
505 Button {
506 viewModel.refreshTrackedDomain(liveTrackedDomain)
507 } label: {
508 Label("Manual Refresh", systemImage: "arrow.clockwise")
509 }
510
511 Button {
512 showRerunOptions = true
513 } label: {
514 Label("Re-run Inspection", systemImage: "magnifyingglass")
515 }
516
517 Button {
518 Task {
519 auditStartInFlight = true
520 if await viewModel.startAudit(for: liveTrackedDomain.domain) != nil {
521 showingAuditTimeline = true
522 }
523 auditStartInFlight = false
524 }
525 } label: {
526 Label(auditStartInFlight ? "Starting Audit…" : "Start Audit", systemImage: "checklist")
527 }
528 .disabled(auditStartInFlight)
529
530 if !viewModel.audits(for: liveTrackedDomain.domain).isEmpty {
531 Button {
532 showingAuditTimeline = true
533 } label: {
534 Label("View Audits", systemImage: "clock.badge.checkmark")
535 }
536 }
537
538 Button {
539 viewModel.togglePinned(for: liveTrackedDomain)
540 } label: {
541 Label(liveTrackedDomain.isPinned ? "Unpin Domain" : "Pin Domain", systemImage: liveTrackedDomain.isPinned ? "pin.slash" : "pin")
542 }
543 .disabled(!viewModel.canEdit(liveTrackedDomain))
544
545 Button {
546 viewModel.toggleMonitoring(for: liveTrackedDomain)
547 } label: {
548 Label(
549 liveTrackedDomain.monitoringEnabled ? "Disable Monitoring" : "Enable Monitoring",
550 systemImage: liveTrackedDomain.monitoringEnabled ? "bell.slash" : "bell"
551 )
552 }
553 .disabled(!viewModel.canEdit(liveTrackedDomain))
554
555 Button {
556 noteDraft = liveTrackedDomain.note ?? ""
557 isEditingNote = true
558 } label: {
559 Label(liveTrackedDomain.note == nil ? "Add Note" : "Edit Note", systemImage: "note.text")
560 }
561 .disabled(!viewModel.canEdit(liveTrackedDomain))
562
563 Button {
564 shareEntity = .trackedDomain(liveTrackedDomain.domain)
565 } label: {
566 Label(liveTrackedDomain.collaboration?.isShared == true ? "Manage Share" : "Share Domain", systemImage: "person.2")
567 }
568 }
569 .listRowBackground(Color(.systemGray6).opacity(0.5))
570
571 Section("Monitoring Status") {
572 LabeledContent("State", value: viewModel.monitoringStatusLabel(for: liveTrackedDomain))
573 LabeledContent("Current Interval", value: viewModel.monitoringIntervalLabel(for: liveTrackedDomain))
574 LabeledContent(
575 "Last Change",
576 value: liveTrackedDomain.monitoringState.lastChangeDate?.formatted(date: .abbreviated, time: .shortened) ?? "None"
577 )
578 if !liveTrackedDomain.pendingMonitoringAlerts.isEmpty {
579 LabeledContent("Queued Alerts", value: "\(liveTrackedDomain.pendingMonitoringAlerts.count)")
580 }
581 }
582 .listRowBackground(Color(.systemGray6).opacity(0.5))
583
584 if let summary = viewModel.latestChangeSummary(for: liveTrackedDomain) {
585 Section("Latest Change Summary") {
586 DomainChangeSummaryView(summary: summary)
587 }
588 .listRowBackground(Color.clear)
589 }
590
591 if !latestDiffSections.isEmpty {
592 Section("Latest Diff") {
593 DomainDiffView(
594 title: "Latest Snapshot vs Previous",
595 sections: latestDiffSections,
596 contextNote: latestSnapshots.count >= 2
597 ? DomainDiffService.comparisonContextNote(from: latestSnapshots[1].snapshot, to: latestSnapshots[0].snapshot)
598 : nil,
599 showsUnchanged: false,
600 highlightedSectionID: nil
601 )
602 }
603 .listRowBackground(Color.clear)
604 }
605
606 Section("Recent Snapshots") {
607 if latestSnapshots.isEmpty {
608 Text("No snapshots yet")
609 .font(.system(.caption, design: .monospaced))
610 .foregroundStyle(.secondary)
611 } else {
612 ForEach(latestSnapshots) { entry in
613 NavigationLink {
614 HistoryDetailView(viewModel: viewModel, entry: entry)
615 } label: {
616 VStack(alignment: .leading, spacing: 4) {
617 Text(entry.timestamp.formatted(date: .abbreviated, time: .shortened))
618 .font(.system(.caption, design: .monospaced))
619 .foregroundStyle(.primary)
620 Text(entry.changeSummary?.hasChanges == true ? "Changed" : "Snapshot")
621 .font(.system(.caption2, design: .monospaced))
622 .foregroundStyle(.secondary)
623 }
624 }
625 }
626 }
627 }
628 .listRowBackground(Color(.systemGray6).opacity(0.5))
629 }
630 .scrollContentBackground(.hidden)
631 .background(Color.black)
632 .navigationTitle(liveTrackedDomain.domain)
633 .preferredColorScheme(.dark)
634 .onChange(of: viewModel.rerunNavigationToken) { _, _ in
635 dismiss()
636 }
637 .confirmationDialog("Re-run inspection", isPresented: $showRerunOptions) {
638 Button("Run with Current Settings") {
639 viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: false)
640 }
641 if latestSnapshots.first != nil {
642 Button("Run with Snapshot Resolver") {
643 viewModel.rerunInspection(for: liveTrackedDomain, useSnapshotResolver: true)
644 }
645 }
646 Button("Cancel", role: .cancel) {}
647 } message: {
648 Text(viewModel.resolverMismatchNote(for: liveTrackedDomain) ?? "Choose how to reproduce the most recent snapshot.")
649 }
650 .sheet(isPresented: $isEditingNote) {
651 NavigationStack {
652 Form {
653 Section("Tracking Note") {
654 TextField("Optional note", text: $noteDraft, axis: .vertical)
655 .lineLimit(3...6)
656 }
657 }
658 .navigationTitle("Edit Note")
659 .toolbar {
660 ToolbarItem(placement: .cancellationAction) {
661 Button("Cancel") {
662 isEditingNote = false
663 }
664 }
665 ToolbarItem(placement: .confirmationAction) {
666 Button("Save") {
667 viewModel.updateNote(noteDraft, for: liveTrackedDomain)
668 isEditingNote = false
669 }
670 }
671 }
672 }
673 }
674 .sheet(item: $shareEntity) { entity in
675 CloudSharingSheet(entity: entity, title: liveTrackedDomain.domain)
676 }
677 .sheet(isPresented: $showingAuditTimeline) {
678 NavigationStack {
679 AuditDomainTimelineView(viewModel: viewModel, domain: liveTrackedDomain.domain)
680 }
681 }
682 }
683}