mirror of
https://github.com/kean/Pulse.git
synced 2026-05-30 21:07:33 +00:00
218 lines
9.5 KiB
Swift
218 lines
9.5 KiB
Swift
// The MIT License (MIT)
|
||
//
|
||
// Copyright (c) 2020–2023 Alexander Grebenyuk (github.com/kean).
|
||
|
||
import SwiftUI
|
||
import Pulse
|
||
|
||
struct KeyValueSectionViewModel {
|
||
var title: String
|
||
var color: Color
|
||
var items: [(String, String?)] = []
|
||
}
|
||
|
||
extension KeyValueSectionViewModel {
|
||
static func makeParameters(for request: NetworkRequestEntity) -> KeyValueSectionViewModel {
|
||
var items: [(String, String?)] = [
|
||
("Cache Policy", request.cachePolicy.description),
|
||
("Timeout Interval", DurationFormatter.string(from: TimeInterval(request.timeoutInterval), isPrecise: false))
|
||
]
|
||
// Display only non-default values
|
||
if !request.allowsCellularAccess {
|
||
items.append(("Allows Cellular Access", request.allowsCellularAccess.description))
|
||
}
|
||
if !request.allowsExpensiveNetworkAccess {
|
||
items.append(("Allows Expensive Network Access", request.allowsExpensiveNetworkAccess.description))
|
||
}
|
||
if !request.allowsConstrainedNetworkAccess {
|
||
items.append(("Allows Constrained Network Access", request.allowsConstrainedNetworkAccess.description))
|
||
}
|
||
if !request.httpShouldHandleCookies {
|
||
items.append(("Should Handle Cookies", request.httpShouldHandleCookies.description))
|
||
}
|
||
if request.httpShouldUsePipelining {
|
||
items.append(("HTTP Should Use Pipelining", request.httpShouldUsePipelining.description))
|
||
}
|
||
return KeyValueSectionViewModel(title: "Options", color: .indigo, items: items)
|
||
}
|
||
|
||
static func makeTaskDetails(for task: NetworkTaskEntity) -> KeyValueSectionViewModel? {
|
||
func format(size: Int64) -> String {
|
||
size > 0 ? ByteCountFormatter.string(fromByteCount: size) : "Empty"
|
||
}
|
||
let taskType = task.type?.urlSessionTaskClassName ?? "URLSessionDataTask"
|
||
return KeyValueSectionViewModel(title: taskType, color: .primary, items: [
|
||
("Host", task.url.flatMap(URL.init)?.host),
|
||
("Date", task.startDate.map(DateFormatter.fullDateFormatter.string)),
|
||
("Duration", task.duration > 0 ? DurationFormatter.string(from: task.duration) : nil)
|
||
])
|
||
}
|
||
|
||
static func makeComponents(for url: URL) -> KeyValueSectionViewModel? {
|
||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||
return nil
|
||
}
|
||
return KeyValueSectionViewModel(
|
||
title: "URL Components",
|
||
color: .blue,
|
||
items: [
|
||
("Scheme", components.scheme),
|
||
("Port", components.port?.description),
|
||
("User", components.user),
|
||
("Password", components.password),
|
||
("Host", components.host),
|
||
("Path", components.path),
|
||
("Query", components.query),
|
||
("Fragment", components.fragment)
|
||
].filter { $0.1?.isEmpty == false })
|
||
}
|
||
|
||
static func makeHeaders(title: String, headers: [String: String]?) -> KeyValueSectionViewModel {
|
||
KeyValueSectionViewModel(
|
||
title: title,
|
||
color: .red,
|
||
items: (headers ?? [:]).sorted {
|
||
// Display cookies last because they typically take too much space
|
||
$1.key.lowercased().contains("cookies") || $0.key < $1.key
|
||
}
|
||
)
|
||
}
|
||
|
||
static func makeErrorDetails(for task: NetworkTaskEntity) -> KeyValueSectionViewModel? {
|
||
guard task.errorCode != 0, task.state == .failure else {
|
||
return nil
|
||
}
|
||
return KeyValueSectionViewModel(
|
||
title: "Error",
|
||
color: .red,
|
||
items: [
|
||
("Domain", task.errorDomain),
|
||
("Code", descriptionForError(domain: task.errorDomain, code: task.errorCode)),
|
||
("Description", task.errorDebugDescription)
|
||
])
|
||
}
|
||
|
||
private static func descriptionForError(domain: String?, code: Int32) -> String {
|
||
guard domain == NSURLErrorDomain else {
|
||
return "\(code)"
|
||
}
|
||
return "\(code) (\(descriptionForURLErrorCode(Int(code))))"
|
||
}
|
||
|
||
static func makeQueryItems(for url: URL) -> KeyValueSectionViewModel? {
|
||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||
let queryItems = components.queryItems,
|
||
!queryItems.isEmpty else {
|
||
return nil
|
||
}
|
||
return makeQueryItems(for: queryItems)
|
||
}
|
||
|
||
static func makeQueryItems(for queryItems: [URLQueryItem]) -> KeyValueSectionViewModel? {
|
||
KeyValueSectionViewModel(
|
||
title: "Query Items",
|
||
color: .purple,
|
||
items: queryItems.map { ($0.name, $0.value) }
|
||
)
|
||
}
|
||
|
||
static func makeDetails(for transaction: NetworkTransactionMetricsEntity) -> [KeyValueSectionViewModel] {
|
||
return [
|
||
makeTiming(for: transaction),
|
||
makeTransferSection(for: transaction),
|
||
makeProtocolSection(for: transaction),
|
||
makeMiscSection(for: transaction),
|
||
makeSecuritySection(for: transaction)
|
||
].compactMap { $0 }
|
||
}
|
||
|
||
private static func makeTiming(for transaction: NetworkTransactionMetricsEntity) -> KeyValueSectionViewModel {
|
||
let timeFormatter = DateFormatter()
|
||
timeFormatter.locale = Locale(identifier: "en_US")
|
||
timeFormatter.dateFormat = "hh:mm:ss.SSS"
|
||
|
||
var startDate: Date?
|
||
var items: [(String, String?)] = []
|
||
func addDate(_ date: Date?, title: String) {
|
||
guard let date = date else { return }
|
||
if items.isEmpty {
|
||
startDate = date
|
||
}
|
||
var value = timeFormatter.string(from: date)
|
||
if let startDate = startDate, startDate != date {
|
||
let duration = date.timeIntervalSince(startDate)
|
||
value += " (+\(DurationFormatter.string(from: duration)))"
|
||
}
|
||
items.append((title, value))
|
||
}
|
||
let timing = transaction.timing
|
||
addDate(timing.fetchStartDate, title: "Fetch Start")
|
||
addDate(timing.domainLookupStartDate, title: "Domain Lookup Start")
|
||
addDate(timing.domainLookupEndDate, title: "Domain Lookup End")
|
||
addDate(timing.connectStartDate, title: "Connect Start")
|
||
addDate(timing.secureConnectionStartDate, title: "Secure Connect Start")
|
||
addDate(timing.secureConnectionEndDate, title: "Secure Connect End")
|
||
addDate(timing.connectEndDate, title: "Connect End")
|
||
addDate(timing.requestStartDate, title: "Request Start")
|
||
addDate(timing.requestEndDate, title: "Request End")
|
||
addDate(timing.responseStartDate, title: "Response Start")
|
||
addDate(timing.responseEndDate, title: "Response End")
|
||
#if !os(watchOS)
|
||
let longestTitleCount = items.map(\.0.count).max() ?? 0
|
||
items = items.map {
|
||
($0.0.padding(toLength: longestTitleCount + 1, withPad: " ", startingAt: 0), $0.1)
|
||
}
|
||
#endif
|
||
return KeyValueSectionViewModel(title: "Timing", color: .orange, items: items)
|
||
}
|
||
|
||
private static func makeTransferSection(for metrics: NetworkTransactionMetricsEntity) -> KeyValueSectionViewModel? {
|
||
let transferSize = metrics.transferSize
|
||
return KeyValueSectionViewModel(title: "Data Transfer", color: .primary, items: [
|
||
("Request Headers", formatBytes(transferSize.requestHeaderBytesSent)),
|
||
("Request Body", formatBytes(transferSize.requestBodyBytesBeforeEncoding)),
|
||
("Request Body (Encoded)", formatBytes(transferSize.requestBodyBytesSent)),
|
||
("Response Headers", formatBytes(transferSize.responseHeaderBytesReceived)),
|
||
("Response Body", formatBytes(transferSize.responseBodyBytesReceived)),
|
||
("Response Body (Decoded)", formatBytes(transferSize.responseBodyBytesAfterDecoding))
|
||
])
|
||
}
|
||
|
||
private static func makeProtocolSection(for metrics: NetworkTransactionMetricsEntity) -> KeyValueSectionViewModel? {
|
||
KeyValueSectionViewModel(title: "Protocol", color: .primary, items: [
|
||
("Network Protocol", metrics.networkProtocol),
|
||
("Remote Address", metrics.remoteAddress),
|
||
("Remote Port", metrics.remotePort > 0 ? String(metrics.remotePort) : nil),
|
||
("Local Address", metrics.localAddress),
|
||
("Local Port", metrics.localPort > 0 ? String(metrics.localPort) : nil)
|
||
])
|
||
}
|
||
|
||
private static func makeSecuritySection(for metrics: NetworkTransactionMetricsEntity) -> KeyValueSectionViewModel? {
|
||
guard let suite = metrics.negotiatedTLSCipherSuite,
|
||
let version = metrics.negotiatedTLSProtocolVersion else {
|
||
return nil
|
||
}
|
||
return KeyValueSectionViewModel(title: "Security", color: .primary, items: [
|
||
("Cipher Suite", suite.description),
|
||
("Protocol Version", version.description)
|
||
])
|
||
}
|
||
|
||
private static func makeMiscSection(for metrics: NetworkTransactionMetricsEntity) -> KeyValueSectionViewModel? {
|
||
KeyValueSectionViewModel(title: "Characteristics", color: .primary, items: [
|
||
("Cellular", metrics.isCellular.description),
|
||
("Expensive", metrics.isExpensive.description),
|
||
("Constrained", metrics.isConstrained.description),
|
||
("Proxy Connection", metrics.isProxyConnection.description),
|
||
("Reused Connection", metrics.isReusedConnection.description),
|
||
("Multipath", metrics.isMultipath.description)
|
||
])
|
||
}
|
||
}
|
||
|
||
private func formatBytes(_ count: Int64) -> String {
|
||
ByteCountFormatter.string(fromByteCount: count)
|
||
}
|
||
|