import SwiftUI import ProxyCore import GRDB struct DomainDetailView: View { let domain: String @State private var requests: [CapturedTraffic] = [] @State private var searchText = "" @State private var filterChips: [FilterChip] = [ FilterChip(label: "JSON"), FilterChip(label: "Form"), FilterChip(label: "Errors"), FilterChip(label: "HTTPS") ] @State private var observation: AnyDatabaseCancellable? @State private var didStartObservation = false private let trafficRepo = TrafficRepository() private let hardcodedDebugDomain = "okcupid" private let hardcodedDebugNeedle = "jill" private var filteredRequests: [CapturedTraffic] { filteredResults(for: requests) } private var httpsCount: Int { requests.filter { $0.scheme == "https" }.count } private var errorCount: Int { requests.filter { ($0.statusCode ?? 0) >= 400 }.count } private var jsonCount: Int { requests.filter { $0.responseContentType?.contains("json") == true || $0.requestContentType?.contains("json") == true }.count } private var lastSeenText: String { guard let date = requests.first?.startDate else { return "Waiting" } return date.formatted(.relative(presentation: .named)) } private var activeFilterLabels: String { let labels = filterChips.filter(\.isSelected).map(\.label) return labels.isEmpty ? "none" : labels.joined(separator: ",") } var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 16) { summaryCard filtersCard if filteredRequests.isEmpty { emptyStateCard } else { LazyVStack(spacing: 12) { ForEach(filteredRequests) { request in if let id = request.id { NavigationLink(value: id) { TrafficRowView(traffic: request) } .buttonStyle(.plain) } } } } } .padding(.horizontal, 16) .padding(.vertical, 14) .padding(.bottom, 28) } .background(screenBackground) .scrollIndicators(.hidden) .searchable(text: $searchText, prompt: "Search path, method, status, or response body") .navigationTitle(domain) .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: Int64.self) { id in RequestDetailView(trafficId: id) } .onAppear { ProxyLogger.ui.info("DomainDetailView[\(domain)]: onAppear requests=\(requests.count)") } .onDisappear { ProxyLogger.ui.info("DomainDetailView[\(domain)]: onDisappear requests=\(requests.count)") observation?.cancel() observation = nil didStartObservation = false } .onChange(of: searchText) { _, newValue in ProxyLogger.ui.info("DomainDetailView[\(domain)]: search changed text=\(newValue)") if newValue.localizedCaseInsensitiveContains(hardcodedDebugNeedle) { logHardcodedSearchDebug(requests: requests, source: "searchChanged") } } .onChange(of: filterChips) { _, _ in ProxyLogger.ui.info("DomainDetailView[\(domain)]: filters changed active=\(activeFilterLabels)") if searchText.localizedCaseInsensitiveContains(hardcodedDebugNeedle) { logHardcodedSearchDebug(requests: requests, source: "filtersChanged") } } .task { guard !didStartObservation else { ProxyLogger.ui.info("DomainDetailView[\(domain)]: task rerun ignored; observation already active") return } didStartObservation = true ProxyLogger.ui.info("DomainDetailView[\(domain)]: starting observation") observation = trafficRepo.observeTraffic(forDomain: domain) .start(in: DatabaseManager.shared.dbPool) { error in ProxyLogger.ui.error("DomainDetailView[\(domain)]: observation error \(error.localizedDescription)") } onChange: { newRequests in let filteredCount = filteredResults(for: newRequests).count let preview = newRequests.prefix(3).compactMap { request -> String? in guard let id = request.id else { return request.method } return "#\(id):\(request.method)" }.joined(separator: ", ") ProxyLogger.ui.info( "DomainDetailView[\(domain)]: requests updated count=\(newRequests.count) filtered=\(filteredCount) preview=\(preview)" ) logHardcodedSearchDebug(requests: newRequests, source: "observation") withAnimation(.snappy) { requests = newRequests } } } } private var summaryCard: some View { DomainSurfaceCard { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text(domain) .font(.title3.weight(.semibold)) .foregroundStyle(.primary) Text("Scan recent calls, errors, payload types, and request timing for this host.") .font(.subheadline) .foregroundStyle(.secondary) } LazyVGrid(columns: summaryColumns, spacing: 10) { summaryMetric("Requests", value: "\(requests.count)", systemImage: "point.3.connected.trianglepath.dotted") summaryMetric("HTTPS", value: "\(httpsCount)", systemImage: "lock.fill") summaryMetric("Errors", value: "\(errorCount)", systemImage: "exclamationmark.triangle.fill") summaryMetric("JSON", value: "\(jsonCount)", systemImage: "curlybraces") } HStack(spacing: 8) { domainTag("Last seen \(lastSeenText)", systemImage: "clock") if !searchText.isEmpty { domainTag("Searching", systemImage: "magnifyingglass", tint: .accentColor) } } } } } private var filtersCard: some View { DomainSurfaceCard { VStack(alignment: .leading, spacing: 14) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 4) { Text("Refine Requests") .font(.headline) Text("\(filteredRequests.count) of \(requests.count) shown") .font(.caption) .foregroundStyle(.secondary) } Spacer() if filterChips.contains(where: \.isSelected) { Button("Clear") { withAnimation(.snappy) { for index in filterChips.indices { filterChips[index].isSelected = false } } } .font(.caption.weight(.semibold)) } } FilterChipsView(chips: $filterChips) } } } private var emptyStateCard: some View { DomainSurfaceCard { EmptyStateView( icon: "line.3.horizontal.decrease.circle", title: requests.isEmpty ? "No Requests Yet" : "No Matching Requests", subtitle: requests.isEmpty ? "Traffic for this domain will appear here as soon as the app captures it." : "Try a different search or clear one of the active filters." ) } } private var summaryColumns: [GridItem] { [GridItem(.flexible()), GridItem(.flexible())] } @ViewBuilder private func summaryMetric(_ title: String, value: String, systemImage: String) -> some View { VStack(alignment: .leading, spacing: 8) { Label(title, systemImage: systemImage) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Text(value) .font(.title3.weight(.semibold)) .foregroundStyle(.primary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(14) .background(Color(.systemBackground).opacity(0.75), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) } @ViewBuilder private func domainTag(_ text: String, systemImage: String, tint: Color = .secondary) -> some View { Label(text, systemImage: systemImage) .font(.caption.weight(.semibold)) .foregroundStyle(tint) .padding(.horizontal, 12) .padding(.vertical, 8) .background(tint.opacity(0.10), in: Capsule()) } private var screenBackground: some View { LinearGradient( colors: [ Color(.systemGroupedBackground), Color(.secondarySystemGroupedBackground) ], startPoint: .top, endPoint: .bottom ) .ignoresSafeArea() } private func filteredResults(for requests: [CapturedTraffic]) -> [CapturedTraffic] { var result = requests if !searchText.isEmpty { result = result.filter { $0.url.localizedCaseInsensitiveContains(searchText) || $0.method.localizedCaseInsensitiveContains(searchText) || ($0.statusText?.localizedCaseInsensitiveContains(searchText) == true) || ($0.searchableResponseBodyText?.localizedCaseInsensitiveContains(searchText) == true) } } let activeFilters = Set(filterChips.filter(\.isSelected).map(\.label)) if !activeFilters.isEmpty { result = result.filter { request in activeFilters.contains { filter in switch filter { case "JSON": return request.responseContentType?.contains("json") == true || request.requestContentType?.contains("json") == true case "Form": return request.requestContentType?.contains("form") == true case "Errors": return (request.statusCode ?? 0) >= 400 case "HTTPS": return request.scheme == "https" default: return false } } } } return result } private func logHardcodedSearchDebug(requests: [CapturedTraffic], source: String) { guard domain.localizedCaseInsensitiveContains(hardcodedDebugDomain) || requests.contains(where: { $0.domain.localizedCaseInsensitiveContains(hardcodedDebugDomain) || $0.url.localizedCaseInsensitiveContains(hardcodedDebugDomain) }) || searchText.localizedCaseInsensitiveContains(hardcodedDebugNeedle) else { return } let matchingRequests = requests.filter { request in request.searchableResponseBodyText?.localizedCaseInsensitiveContains(hardcodedDebugNeedle) == true } if matchingRequests.isEmpty { ProxyLogger.ui.info( "HARDCODED DEBUG search source=\(source) domain=\(domain) needle=\(hardcodedDebugNeedle) found=0 total=\(requests.count) searchText=\(searchText)" ) logHardcodedRequestDiagnostics(requests: requests, source: source) return } let filtered = filteredResults(for: requests) for request in matchingRequests { let isVisibleInFilteredResults = filtered.contains { $0.id == request.id } let bodyPreview = String((request.searchableResponseBodyText ?? "").prefix(180)) .replacingOccurrences(of: "\n", with: " ") ProxyLogger.ui.info( """ HARDCODED DEBUG search source=\(source) domain=\(domain) needle=\(hardcodedDebugNeedle) \ requestId=\(request.id ?? -1) visible=\(isVisibleInFilteredResults) url=\(request.url) \ status=\(request.statusCode ?? -1) preview=\(bodyPreview) """ ) } } private func logHardcodedRequestDiagnostics(requests: [CapturedTraffic], source: String) { for request in requests.prefix(20) { let contentEncoding = request.responseHeaderValue(named: "Content-Encoding") ?? "nil" let contentType = request.responseContentType ?? "nil" let bodySize = request.responseBody?.count ?? 0 let decodedBodySize = request.decodedResponseBodyData?.count ?? 0 let decodingHint = request.responseBodyDecodingHint let gzipMagic = hasGzipMagic(request.responseBody) let preview = String((request.searchableResponseBodyText ?? "").prefix(140)) .replacingOccurrences(of: "\n", with: " ") ProxyLogger.ui.info( """ HARDCODED DEBUG body source=\(source) domain=\(domain) requestId=\(request.id ?? -1) \ status=\(request.statusCode ?? -1) contentType=\(contentType) contentEncoding=\(contentEncoding) \ bodyBytes=\(bodySize) decodedBytes=\(decodedBodySize) decoding=\(decodingHint) \ gzipMagic=\(gzipMagic) preview=\(preview) """ ) } } private func hasGzipMagic(_ data: Data?) -> Bool { guard let data, data.count >= 2 else { return false } return data[data.startIndex] == 0x1f && data[data.index(after: data.startIndex)] == 0x8b } } private struct DomainSurfaceCard: View { let content: Content init(@ViewBuilder content: () -> Content) { self.content = content() } var body: some View { ZStack { RoundedRectangle(cornerRadius: 26, style: .continuous) .fill(Color(.secondarySystemGroupedBackground)) RoundedRectangle(cornerRadius: 26, style: .continuous) .fill( LinearGradient( colors: [ Color.accentColor.opacity(0.10), .clear, Color.blue.opacity(0.05) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) content .padding(18) .frame(maxWidth: .infinity, alignment: .leading) } .overlay( RoundedRectangle(cornerRadius: 26, style: .continuous) .strokeBorder(Color.primary.opacity(0.05), lineWidth: 1) ) } }