Skip to content

Commit 75a79c9

Browse files
committed
Add connections dashboard
1 parent aa4ce98 commit 75a79c9

File tree

18 files changed

+749
-44
lines changed

18 files changed

+749
-44
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import Foundation
2+
3+
public struct Connection: Codable {
4+
public let id: String
5+
public let inbound: String
6+
public let inboundType: String
7+
public let ipVersion: Int32
8+
public let network: String
9+
public let source: String
10+
public let destination: String
11+
public let domain: String
12+
public let displayDestination: String
13+
public let protocolName: String
14+
public let user: String
15+
public let fromOutbound: String
16+
public let createdAt: Date
17+
public let closedAt: Date?
18+
public var upload: Int64
19+
public var download: Int64
20+
public var uploadTotal: Int64
21+
public var downloadTotal: Int64
22+
public let rule: String
23+
public let outbound: String
24+
public let outboundType: String
25+
public let chain: [String]
26+
27+
var hashValue: Int {
28+
var value = id.hashValue
29+
(value, _) = value.addingReportingOverflow(upload.hashValue)
30+
(value, _) = value.addingReportingOverflow(download.hashValue)
31+
(value, _) = value.addingReportingOverflow(uploadTotal.hashValue)
32+
(value, _) = value.addingReportingOverflow(downloadTotal.hashValue)
33+
return value
34+
}
35+
36+
func performSearch(_ content: String) -> Bool {
37+
for item in content.components(separatedBy: " ") {
38+
let itemSep = item.components(separatedBy: ":")
39+
if itemSep.count == 2 {
40+
if !performSearchType(type: itemSep[0], value: itemSep[1]) {
41+
return false
42+
}
43+
continue
44+
}
45+
if !performSearchPlain(item) {
46+
return false
47+
}
48+
}
49+
return true
50+
}
51+
52+
private func performSearchPlain(_ content: String) -> Bool {
53+
destination.contains(content) ||
54+
domain.contains(content)
55+
}
56+
57+
private func performSearchType(type: String, value: String) -> Bool {
58+
switch type {
59+
// TODO: impl more
60+
case "network":
61+
return network == value
62+
case "inbound":
63+
return inbound.contains(value)
64+
case "inbound.type":
65+
return inboundType == value
66+
case "source":
67+
return source.contains(value)
68+
case "destination":
69+
return destination.contains(value)
70+
default:
71+
return false
72+
}
73+
}
74+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import Foundation
2+
import Libbox
3+
import SwiftUI
4+
5+
public struct ConnectionDetailsView: View {
6+
private let connection: Connection
7+
public init(_ connection: Connection) {
8+
self.connection = connection
9+
}
10+
11+
public var body: some View {
12+
FormView {
13+
if connection.closedAt != nil {
14+
FormTextItem("State", "Closed")
15+
FormTextItem("Created At", connection.createdAt.myFormat)
16+
} else {
17+
FormTextItem("State", "Active")
18+
FormTextItem("Created At", connection.createdAt.myFormat)
19+
}
20+
if let closedAt = connection.closedAt {
21+
FormTextItem("Closed At", closedAt.myFormat)
22+
}
23+
FormTextItem("Upload", LibboxFormatBytes(connection.uploadTotal))
24+
FormTextItem("Download", LibboxFormatBytes(connection.downloadTotal))
25+
Section("Metadata") {
26+
FormTextItem("Inbound", connection.inbound)
27+
FormTextItem("Inbound Type", connection.inboundType)
28+
FormTextItem("IP Version", "\(connection.ipVersion)")
29+
FormTextItem("Network", connection.network.uppercased())
30+
FormTextItem("Source", connection.source)
31+
FormTextItem("Destination", connection.destination)
32+
if !connection.domain.isEmpty {
33+
FormTextItem("Domain", connection.domain)
34+
}
35+
if !connection.protocolName.isEmpty {
36+
FormTextItem("Protocol", connection.protocolName)
37+
}
38+
if !connection.user.isEmpty {
39+
FormTextItem("User", connection.user)
40+
}
41+
if !connection.fromOutbound.isEmpty {
42+
FormTextItem("From Outbound", connection.fromOutbound)
43+
}
44+
if !connection.rule.isEmpty {
45+
FormTextItem("Match Rule", connection.rule)
46+
}
47+
FormTextItem("Outbound", connection.outbound)
48+
FormTextItem("Outbound Type", connection.outboundType)
49+
if connection.chain.count > 1 {
50+
FormTextItem("Chain", connection.chain.reversed().joined(separator: "/"))
51+
}
52+
}
53+
}
54+
.navigationTitle("Connection")
55+
}
56+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
public enum ConnectionListPage: Int, CaseIterable, Identifiable {
5+
public var id: Self {
6+
self
7+
}
8+
9+
case active
10+
case closed
11+
}
12+
13+
public extension ConnectionListPage {
14+
var title: String {
15+
switch self {
16+
case .active:
17+
return NSLocalizedString("Active", comment: "")
18+
case .closed:
19+
return NSLocalizedString("Closed", comment: "")
20+
}
21+
}
22+
23+
var label: some View {
24+
switch self {
25+
case .active:
26+
return Label(title, systemImage: "play.fill")
27+
case .closed:
28+
return Label(title, systemImage: "stop.fill")
29+
}
30+
}
31+
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import Libbox
2+
import Library
3+
import SwiftUI
4+
5+
@MainActor
6+
public struct ConnectionListView: View {
7+
@Environment(\.scenePhase) private var scenePhase
8+
@State private var isLoading = true
9+
@StateObject private var commandClient = CommandClient(.connections)
10+
@State private var connections: [Connection] = []
11+
@State private var selection: ConnectionListPage = .active
12+
@State private var searchText = ""
13+
@State private var alert: Alert?
14+
15+
public init() {}
16+
public var body: some View {
17+
VStack {
18+
if isLoading {
19+
Text("Loading...")
20+
} else {
21+
if connections.isEmpty {
22+
Text("Empty connections")
23+
} else {
24+
ScrollView {
25+
LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading) {
26+
ForEach(connections.filter { it in
27+
searchText == "" || it.performSearch(searchText)
28+
}, id: \.hashValue) { it in
29+
ConnectionView(it)
30+
}
31+
}
32+
}
33+
.padding()
34+
}
35+
}
36+
}
37+
.toolbar {
38+
ToolbarItem {
39+
Menu {
40+
Picker("State", selection: $commandClient.connectionStateFilter) {
41+
ForEach(ConnectionStateFilter.allCases) { state in
42+
Text(state.name)
43+
}
44+
}
45+
46+
Picker("Sort By", selection: $commandClient.connectionSort) {
47+
ForEach(ConnectionSort.allCases, id: \.self) { sortBy in
48+
Text(sortBy.name)
49+
}
50+
}
51+
52+
Button("Close All Connections", role: .destructive) {
53+
do {
54+
try LibboxNewStandaloneCommandClient()!.closeConnections()
55+
} catch {
56+
alert = Alert(error)
57+
}
58+
}
59+
} label: {
60+
Label("Filter", systemImage: "line.3.horizontal.circle")
61+
}
62+
}
63+
}
64+
.searchable(text: $searchText)
65+
.alertBinding($alert)
66+
.onAppear {
67+
connect()
68+
}
69+
.onDisappear {
70+
commandClient.disconnect()
71+
}
72+
.onChangeCompat(of: scenePhase) { newValue in
73+
if newValue == .active {
74+
commandClient.connect()
75+
} else {
76+
commandClient.disconnect()
77+
}
78+
}
79+
.onChangeCompat(of: commandClient.connectionStateFilter) { it in
80+
commandClient.filterConnectionsNow()
81+
Task {
82+
await SharedPreferences.connectionStateFilter.set(it.rawValue)
83+
}
84+
}
85+
.onChangeCompat(of: commandClient.connectionSort) { it in
86+
commandClient.filterConnectionsNow()
87+
Task {
88+
await SharedPreferences.connectionSort.set(it.rawValue)
89+
}
90+
}
91+
.onReceive(commandClient.$connections, perform: { connections in
92+
if let connections {
93+
self.connections = convertConnections(connections)
94+
isLoading = false
95+
}
96+
})
97+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
98+
#if os(iOS)
99+
.background(Color(uiColor: .systemGroupedBackground))
100+
#endif
101+
}
102+
103+
private var backgroundColor: Color {
104+
#if os(iOS)
105+
return Color(uiColor: .secondarySystemGroupedBackground)
106+
#elseif os(macOS)
107+
return Color(nsColor: .textBackgroundColor)
108+
#elseif os(tvOS)
109+
return Color(uiColor: .black)
110+
#endif
111+
}
112+
113+
private func connect() {
114+
if ApplicationLibrary.inPreview {
115+
isLoading = false
116+
} else {
117+
commandClient.connect()
118+
}
119+
}
120+
121+
private func convertConnections(_ goConnections: [LibboxConnection]) -> [Connection] {
122+
var connections = [Connection]()
123+
for goConnection in goConnections {
124+
if goConnection.outboundType == "dns" {
125+
continue
126+
}
127+
var closedAt: Date?
128+
if goConnection.closedAt > 0 {
129+
closedAt = Date(timeIntervalSince1970: Double(goConnection.closedAt) / 1000)
130+
}
131+
connections.append(Connection(
132+
id: goConnection.id_,
133+
inbound: goConnection.inbound,
134+
inboundType: goConnection.inboundType,
135+
ipVersion: goConnection.ipVersion,
136+
network: goConnection.network,
137+
source: goConnection.source,
138+
destination: goConnection.destination,
139+
domain: goConnection.domain,
140+
displayDestination: goConnection.displayDestination(),
141+
protocolName: goConnection.protocol,
142+
user: goConnection.user,
143+
fromOutbound: goConnection.fromOutbound,
144+
createdAt: Date(timeIntervalSince1970: Double(goConnection.createdAt) / 1000),
145+
closedAt: closedAt,
146+
upload: goConnection.uplink,
147+
download: goConnection.downlink,
148+
uploadTotal: goConnection.uplinkTotal,
149+
downloadTotal: goConnection.downlinkTotal,
150+
rule: goConnection.rule,
151+
outbound: goConnection.outbound,
152+
outboundType: goConnection.outboundType,
153+
chain: goConnection.chain()!.toArray()
154+
))
155+
}
156+
return connections
157+
}
158+
}

0 commit comments

Comments
 (0)