194 lines
6.7 KiB
Swift
194 lines
6.7 KiB
Swift
import SwiftUI
|
|
import ProxyCore
|
|
|
|
struct RequestDetailView: View {
|
|
let trafficId: Int64
|
|
|
|
@State private var traffic: CapturedTraffic?
|
|
@State private var selectedSegment: Segment = .request
|
|
|
|
private let trafficRepo = TrafficRepository()
|
|
|
|
enum Segment: String, CaseIterable {
|
|
case request = "Request"
|
|
case response = "Response"
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let traffic {
|
|
VStack(spacing: 0) {
|
|
Picker("Segment", selection: $selectedSegment) {
|
|
ForEach(Segment.allCases, id: \.self) { segment in
|
|
Text(segment.rawValue).tag(segment)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding()
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
switch selectedSegment {
|
|
case .request:
|
|
requestContent(traffic)
|
|
case .response:
|
|
responseContent(traffic)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.navigationTitle(traffic?.domain ?? "Request")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
if let traffic {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button {
|
|
try? trafficRepo.togglePin(id: trafficId, isPinned: !traffic.isPinned)
|
|
self.traffic?.isPinned.toggle()
|
|
} label: {
|
|
Image(systemName: traffic.isPinned ? "pin.fill" : "pin")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
traffic = try? trafficRepo.traffic(byId: trafficId)
|
|
}
|
|
}
|
|
|
|
// MARK: - Request Content
|
|
|
|
@ViewBuilder
|
|
private func requestContent(_ traffic: CapturedTraffic) -> some View {
|
|
// General
|
|
DisclosureGroup("General") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
KeyValueRow(key: "URL", value: traffic.url)
|
|
KeyValueRow(key: "Method", value: traffic.method)
|
|
KeyValueRow(key: "Scheme", value: traffic.scheme)
|
|
KeyValueRow(key: "Time", value: traffic.startDate.formatted(.dateTime))
|
|
if let duration = traffic.durationMs {
|
|
KeyValueRow(key: "Duration", value: "\(duration) ms")
|
|
}
|
|
if let status = traffic.statusCode {
|
|
KeyValueRow(key: "Status", value: "\(status) \(traffic.statusText ?? "")")
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// Headers
|
|
let requestHeaders = traffic.decodedRequestHeaders
|
|
if !requestHeaders.isEmpty {
|
|
DisclosureGroup("Headers (\(requestHeaders.count))") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
ForEach(requestHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
|
KeyValueRow(key: key, value: value)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
// Query Parameters
|
|
let queryParams = traffic.decodedQueryParameters
|
|
if !queryParams.isEmpty {
|
|
DisclosureGroup("Query (\(queryParams.count))") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
ForEach(queryParams.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
|
KeyValueRow(key: key, value: value)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
// Body
|
|
if let body = traffic.requestBody, !body.isEmpty {
|
|
DisclosureGroup("Body (\(formatBytes(body.count)))") {
|
|
bodyView(data: body, contentType: traffic.requestContentType)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Response Content
|
|
|
|
@ViewBuilder
|
|
private func responseContent(_ traffic: CapturedTraffic) -> some View {
|
|
if let status = traffic.statusCode {
|
|
// Status
|
|
HStack {
|
|
StatusBadge(statusCode: status)
|
|
Text(traffic.statusText ?? "")
|
|
.font(.subheadline)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
|
|
// Headers
|
|
let responseHeaders = traffic.decodedResponseHeaders
|
|
if !responseHeaders.isEmpty {
|
|
DisclosureGroup("Headers (\(responseHeaders.count))") {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
ForEach(responseHeaders.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
|
|
KeyValueRow(key: key, value: value)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
// Body
|
|
if let body = traffic.responseBody, !body.isEmpty {
|
|
DisclosureGroup("Body (\(formatBytes(body.count)))") {
|
|
bodyView(data: body, contentType: traffic.responseContentType)
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
if traffic.statusCode == nil {
|
|
EmptyStateView(
|
|
icon: "clock",
|
|
title: "Waiting for Response",
|
|
subtitle: "The response has not been received yet."
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Body View
|
|
|
|
@ViewBuilder
|
|
private func bodyView(data: Data, contentType: String?) -> some View {
|
|
if let contentType, contentType.contains("json"),
|
|
let json = try? JSONSerialization.jsonObject(with: data),
|
|
let pretty = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted),
|
|
let string = String(data: pretty, encoding: .utf8) {
|
|
ScrollView(.horizontal) {
|
|
Text(string)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
}
|
|
} else if let string = String(data: data, encoding: .utf8) {
|
|
Text(string)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
} else {
|
|
Text("\(data.count) bytes (binary)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func formatBytes(_ bytes: Int) -> String {
|
|
if bytes < 1024 { return "\(bytes) B" }
|
|
if bytes < 1_048_576 { return String(format: "%.1f KB", Double(bytes) / 1024) }
|
|
return String(format: "%.1f MB", Double(bytes) / 1_048_576)
|
|
}
|
|
}
|