diff options
| author | Christian Cleberg <[email protected]> | 2026-03-18 11:59:38 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-03-18 11:59:38 -0500 |
| commit | 376e7b55b4a0c4f54faa0c2828dea4fd17dff288 (patch) | |
| tree | 540d11ad423f14975553f2490617e05aea61ae2c /DomainDig/HistoryView.swift | |
| parent | e11b7d29a6aa9e52f63fcb35ee7ed9127850fffe (diff) | |
| download | domain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.tar.gz domain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.tar.bz2 domain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.zip | |
realigning commits
Diffstat (limited to 'DomainDig/HistoryView.swift')
| -rw-r--r-- | DomainDig/HistoryView.swift | 496 |
1 files changed, 496 insertions, 0 deletions
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift new file mode 100644 index 0000000..b64ebf3 --- /dev/null +++ b/DomainDig/HistoryView.swift @@ -0,0 +1,496 @@ +import SwiftUI +import MapKit + +struct HistoryView: View { + @Bindable var viewModel: DomainViewModel + @Environment(\.dismiss) private var dismiss + + private let dateFmt: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + + var body: some View { + List { + if viewModel.history.isEmpty { + Text("No lookup history") + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + ForEach(viewModel.history) { entry in + NavigationLink { + HistoryDetailView(entry: entry) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(entry.domain) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + Text(dateFmt.string(from: entry.timestamp)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .onDelete { offsets in + viewModel.removeHistoryEntries(at: offsets) + } + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("History") + .toolbar { + if !viewModel.history.isEmpty { + EditButton() + } + } + .preferredColorScheme(.dark) + } +} + +// MARK: - History Detail View (Read-Only Cached Results) + +struct HistoryDetailView: View { + let entry: HistoryEntry + + private let dateFmt: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + cachedBanner + reachabilitySection + redirectChainSection + dnsSection + emailSecuritySection + sslSection + httpHeadersSection + ipGeolocationSection + portScanSection + } + .padding(.horizontal) + .padding(.bottom, 32) + } + .background(Color.black) + .navigationTitle(entry.domain) + .preferredColorScheme(.dark) + } + + // MARK: - Cached Banner + + private var cachedBanner: some View { + HStack(spacing: 6) { + Image(systemName: "archivebox") + .font(.caption) + Text("Cached result from \(dateFmt.string(from: entry.timestamp))") + .font(.system(.caption, design: .monospaced)) + } + .foregroundStyle(.secondary) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6).opacity(0.3)) + .cornerRadius(6) + .padding(.vertical, 12) + } + + // MARK: - Reachability + + private var reachabilitySection: some View { + VStack(alignment: .leading, spacing: 12) { + if !entry.reachabilityResults.isEmpty { + sectionHeader("Reachability") + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.reachabilityResults) { result in + HStack(spacing: 8) { + Circle() + .fill(result.reachable ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text("Port \(result.port)") + .font(.system(.caption, design: .monospaced)) + if result.reachable, let ms = result.latencyMs { + Text("\(ms)ms") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else if !result.reachable { + Text("—") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + Text(result.reachable ? "Reachable" : "Unreachable") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(result.reachable ? .green : .red) + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 8) + } + + // MARK: - Redirect Chain + + private var redirectChainSection: some View { + VStack(alignment: .leading, spacing: 12) { + if !entry.redirectChain.isEmpty { + sectionHeader("Redirect Chain") + if entry.redirectChain.count == 1, + let only = entry.redirectChain.first, + only.isFinal, !(300...399).contains(only.statusCode) { + Text("No redirects — direct connection") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.redirectChain) { hop in + HStack(alignment: .top, spacing: 6) { + Text("\(hop.stepNumber)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .trailing) + Text("\(hop.statusCode)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.cyan) + .frame(width: 30, alignment: .leading) + Text(hop.url) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + if hop.isFinal { + Text("(final)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + } + .padding(.top, 16) + } + + // MARK: - DNS + + private var dnsSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("DNS Records") + ForEach(entry.dnsSections) { section in + VStack(alignment: .leading, spacing: 4) { + Text(section.recordType.rawValue) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + if let error = section.error { + errorLabel(error) + } else if section.records.isEmpty { + Text("No records found") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else { + recordRows(section.records) + } + + if !section.wildcardRecords.isEmpty { + Text("*.\(entry.domain)") + .font(.system(.caption, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(.cyan.opacity(0.7)) + .padding(.top, 4) + recordRows(section.wildcardRecords) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + + if section.recordType == .A { + VStack(alignment: .leading, spacing: 4) { + Text("PTR (Reverse DNS)") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + if let ptr = entry.ptrRecord { + Text(ptr) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } else { + Text("No PTR record found") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + } + .padding(.top, 16) + } + + // MARK: - Email Security + + @State private var expandedEmailField: String? + + private var emailSecuritySection: some View { + VStack(alignment: .leading, spacing: 12) { + if let email = entry.emailSecurity { + sectionHeader("Email Security") + VStack(alignment: .leading, spacing: 6) { + historyEmailRow("SPF", record: email.spf) + historyEmailRow("DMARC", record: email.dmarc) + historyEmailRow("DKIM", record: email.dkim) + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(label) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.semibold) + .frame(width: 52, alignment: .leading) + Text(record.found ? "✓" : "✗") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(record.found ? .green : .red) + if let value = record.value { + let isExpanded = expandedEmailField == label + let displayValue = isExpanded ? value : String(value.prefix(80)) + Text(displayValue) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .lineLimit(isExpanded ? nil : 1) + .onTapGesture { + withAnimation { + expandedEmailField = isExpanded ? nil : label + } + } + } else { + Text("No record found") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - SSL + + private var sslSection: some View { + VStack(alignment: .leading, spacing: 12) { + if let info = entry.sslInfo { + sectionHeader("SSL / TLS Certificate") + VStack(alignment: .leading, spacing: 8) { + labelRow("Common Name", info.commonName) + labelRow("Issuer", info.issuer) + + VStack(alignment: .leading, spacing: 2) { + Text("SANs") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + ForEach(info.subjectAltNames, id: \.self) { san in + Text(san) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom)) + labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil)) + + HStack { + Text("Days Until Expiry") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + Text("\(info.daysUntilExpiry)") + .font(.system(.caption, design: .monospaced)) + .fontWeight(.bold) + .foregroundStyle(expiryColor(info.daysUntilExpiry)) + } + + labelRow("Chain Depth", "\(info.chainDepth)") + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - HTTP Headers + + private var httpHeadersSection: some View { + VStack(alignment: .leading, spacing: 12) { + if !entry.httpHeaders.isEmpty { + sectionHeader("HTTP Headers") + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.httpHeaders) { header in + HStack(alignment: .top, spacing: 4) { + Text(header.name + ":") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) + Text(header.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - IP Geolocation + + private var ipGeolocationSection: some View { + VStack(alignment: .leading, spacing: 12) { + if let geo = entry.ipGeolocation { + sectionHeader("IP Location") + VStack(alignment: .leading, spacing: 6) { + labelRow("IP", geo.ip) + if let org = geo.org { + labelRow("Org / ISP", org) + } + let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") + if !location.isEmpty { + labelRow("Location", location) + } + if let lat = geo.latitude, let lon = geo.longitude { + let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) + Map(initialPosition: .region(MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) + ))) { + Marker(geo.ip, coordinate: coordinate) + } + .mapStyle(.standard) + .frame(height: 180) + .cornerRadius(8) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - Port Scan + + private var portScanSection: some View { + VStack(alignment: .leading, spacing: 12) { + if !entry.portScanResults.isEmpty { + sectionHeader("Open Ports") + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.portScanResults) { result in + HStack(spacing: 8) { + Circle() + .fill(result.open ? Color.green : Color(.systemGray4)) + .frame(width: 8, height: 8) + Text("\(result.port)") + .font(.system(.caption, design: .monospaced)) + .frame(width: 44, alignment: .leading) + Text(result.service) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(result.open ? .primary : .secondary) + Spacer() + if result.open { + Text("Open") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.green) + } + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - Helpers + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(.headline, design: .default)) + .foregroundStyle(.white) + } + + private func labelRow(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + private func recordRows(_ records: [DNSRecord]) -> some View { + ForEach(records) { record in + HStack(alignment: .top) { + Text(record.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + Spacer() + Text("TTL \(record.ttl)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + + private func errorLabel(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.red) + .padding(8) + } + + private func expiryColor(_ days: Int) -> Color { + if days < 30 { return .red } + if days < 60 { return .yellow } + return .green + } +} |
