Add connections dashboard

This commit is contained in:
世界
2024-10-06 21:41:07 +08:00
parent c223f50ffb
commit 9dadf5f648
17 changed files with 670 additions and 25 deletions
@@ -0,0 +1,74 @@
import Foundation
public struct Connection: Codable {
public let id: String
public let inbound: String
public let inboundType: String
public let ipVersion: Int32
public let network: String
public let source: String
public let destination: String
public let domain: String
public let displayDestination: String
public let protocolName: String
public let user: String
public let fromOutbound: String
public let createdAt: Date
public let closedAt: Date?
public var upload: Int64
public var download: Int64
public var uploadTotal: Int64
public var downloadTotal: Int64
public let rule: String
public let outbound: String
public let outboundType: String
public let chain: [String]
var hashValue: Int {
var value = id.hashValue
(value, _) = value.addingReportingOverflow(upload.hashValue)
(value, _) = value.addingReportingOverflow(download.hashValue)
(value, _) = value.addingReportingOverflow(uploadTotal.hashValue)
(value, _) = value.addingReportingOverflow(downloadTotal.hashValue)
return value
}
func performSearch(_ content: String) -> Bool {
for item in content.components(separatedBy: " ") {
let itemSep = item.components(separatedBy: ":")
if itemSep.count == 2 {
if !performSearchType(type: itemSep[0], value: itemSep[1]) {
return false
}
continue
}
if !performSearchPlain(item) {
return false
}
}
return true
}
private func performSearchPlain(_ content: String) -> Bool {
destination.contains(content) ||
domain.contains(content)
}
private func performSearchType(type: String, value: String) -> Bool {
switch type {
// TODO: impl more
case "network":
return network == value
case "inbound":
return inbound.contains(value)
case "inbound.type":
return inboundType == value
case "source":
return source.contains(value)
case "destination":
return destination.contains(value)
default:
return false
}
}
}
@@ -0,0 +1,56 @@
import Foundation
import Libbox
import SwiftUI
public struct ConnectionDetailsView: View {
private let connection: Connection
public init(_ connection: Connection) {
self.connection = connection
}
public var body: some View {
FormView {
if connection.closedAt != nil {
FormTextItem("State", "Closed")
FormTextItem("Created At", connection.createdAt.myFormat)
} else {
FormTextItem("State", "Active")
FormTextItem("Created At", connection.createdAt.myFormat)
}
if let closedAt = connection.closedAt {
FormTextItem("Closed At", closedAt.myFormat)
}
FormTextItem("Upload", LibboxFormatBytes(connection.uploadTotal))
FormTextItem("Download", LibboxFormatBytes(connection.downloadTotal))
Section("Metadata") {
FormTextItem("Inbound", connection.inbound)
FormTextItem("Inbound Type", connection.inboundType)
FormTextItem("IP Version", "\(connection.ipVersion)")
FormTextItem("Network", connection.network.uppercased())
FormTextItem("Source", connection.source)
FormTextItem("Destination", connection.destination)
if !connection.domain.isEmpty {
FormTextItem("Domain", connection.domain)
}
if !connection.protocolName.isEmpty {
FormTextItem("Protocol", connection.protocolName)
}
if !connection.user.isEmpty {
FormTextItem("User", connection.user)
}
if !connection.fromOutbound.isEmpty {
FormTextItem("From Outbound", connection.fromOutbound)
}
if !connection.rule.isEmpty {
FormTextItem("Match Rule", connection.rule)
}
FormTextItem("Outbound", connection.outbound)
FormTextItem("Outbound Type", connection.outboundType)
if connection.chain.count > 1 {
FormTextItem("Chain", connection.chain.reversed().joined(separator: "/"))
}
}
}
.navigationTitle("Connection")
}
}
@@ -0,0 +1,31 @@
import Foundation
import SwiftUI
public enum ConnectionListPage: Int, CaseIterable, Identifiable {
public var id: Self {
self
}
case active
case closed
}
public extension ConnectionListPage {
var title: String {
switch self {
case .active:
return NSLocalizedString("Active", comment: "")
case .closed:
return NSLocalizedString("Closed", comment: "")
}
}
var label: some View {
switch self {
case .active:
return Label(title, systemImage: "play.fill")
case .closed:
return Label(title, systemImage: "stop.fill")
}
}
}
@@ -0,0 +1,158 @@
import Libbox
import Library
import SwiftUI
@MainActor
public struct ConnectionListView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var isLoading = true
@StateObject private var commandClient = CommandClient(.connections)
@State private var connections: [Connection] = []
@State private var selection: ConnectionListPage = .active
@State private var searchText = ""
@State private var alert: Alert?
public init() {}
public var body: some View {
VStack {
if isLoading {
Text("Loading...")
} else {
if connections.isEmpty {
Text("Empty connections")
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible())], alignment: .leading) {
ForEach(connections.filter { it in
searchText == "" || it.performSearch(searchText)
}, id: \.hashValue) { it in
ConnectionView(it)
}
}
}
.padding()
}
}
}
.toolbar {
ToolbarItem {
Menu {
Picker("State", selection: $commandClient.connectionStateFilter) {
ForEach(ConnectionStateFilter.allCases) { state in
Text(state.name)
}
}
Picker("Sort By", selection: $commandClient.connectionSort) {
ForEach(ConnectionSort.allCases, id: \.self) { sortBy in
Text(sortBy.name)
}
}
Button("Close All Connections", role: .destructive) {
do {
try LibboxNewStandaloneCommandClient()!.closeConnections()
} catch {
alert = Alert(error)
}
}
} label: {
Label("Filter", systemImage: "line.3.horizontal.circle")
}
}
}
.searchable(text: $searchText)
.alertBinding($alert)
.onAppear {
connect()
}
.onDisappear {
commandClient.disconnect()
}
.onChangeCompat(of: scenePhase) { newValue in
if newValue == .active {
commandClient.connect()
} else {
commandClient.disconnect()
}
}
.onChangeCompat(of: commandClient.connectionStateFilter) { it in
commandClient.filterConnectionsNow()
Task {
await SharedPreferences.connectionStateFilter.set(it.rawValue)
}
}
.onChangeCompat(of: commandClient.connectionSort) { it in
commandClient.filterConnectionsNow()
Task {
await SharedPreferences.connectionSort.set(it.rawValue)
}
}
.onReceive(commandClient.$connections, perform: { connections in
if let connections {
self.connections = convertConnections(connections)
isLoading = false
}
})
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
#if os(iOS)
.background(Color(uiColor: .systemGroupedBackground))
#endif
}
private var backgroundColor: Color {
#if os(iOS)
return Color(uiColor: .secondarySystemGroupedBackground)
#elseif os(macOS)
return Color(nsColor: .textBackgroundColor)
#elseif os(tvOS)
return Color(uiColor: .black)
#endif
}
private func connect() {
if ApplicationLibrary.inPreview {
isLoading = false
} else {
commandClient.connect()
}
}
private func convertConnections(_ goConnections: [LibboxConnection]) -> [Connection] {
var connections = [Connection]()
for goConnection in goConnections {
if goConnection.outboundType == "dns" {
continue
}
var closedAt: Date?
if goConnection.closedAt > 0 {
closedAt = Date(timeIntervalSince1970: Double(goConnection.closedAt) / 1000)
}
connections.append(Connection(
id: goConnection.id_,
inbound: goConnection.inbound,
inboundType: goConnection.inboundType,
ipVersion: goConnection.ipVersion,
network: goConnection.network,
source: goConnection.source,
destination: goConnection.destination,
domain: goConnection.domain,
displayDestination: goConnection.displayDestination(),
protocolName: goConnection.protocol,
user: goConnection.user,
fromOutbound: goConnection.fromOutbound,
createdAt: Date(timeIntervalSince1970: Double(goConnection.createdAt) / 1000),
closedAt: closedAt,
upload: goConnection.uplink,
download: goConnection.downlink,
uploadTotal: goConnection.uplinkTotal,
downloadTotal: goConnection.downlinkTotal,
rule: goConnection.rule,
outbound: goConnection.outbound,
outboundType: goConnection.outboundType,
chain: goConnection.chain()!.toArray()
))
}
return connections
}
}
@@ -0,0 +1,121 @@
import Libbox
import SwiftUI
@MainActor
public struct ConnectionView: View {
private let connection: Connection
public init(_ connection: Connection) {
self.connection = connection
}
private func format(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter.string(from: date)
}
public func formatInterval(_ createdAt: Date, _ closedAt: Date) -> String {
LibboxFormatDuration(Int64((closedAt.timeIntervalSince1970 - createdAt.timeIntervalSince1970) * 1000))
}
@State private var alert: Alert?
public var body: some View {
FormNavigationLink {
ConnectionDetailsView(connection)
} label: {
VStack {
HStack {
VStack(alignment: .leading) {
HStack(alignment: .center) {
Text("\(connection.network.uppercased()) \(connection.displayDestination)")
Spacer()
if connection.closedAt == nil {
Text("Active").foregroundStyle(.green)
} else {
Text("Closed").foregroundStyle(.red)
}
}
.font(.caption2.monospaced().bold())
.padding([.bottom], 4)
HStack {
if let closedAt = connection.closedAt {
VStack(alignment: .leading) {
Text("\(LibboxFormatBytes(connection.uploadTotal))")
Text("\(LibboxFormatBytes(connection.downloadTotal))")
}
.font(.caption2)
VStack(alignment: .leading) {
Text(format(connection.createdAt))
Text(formatInterval(connection.createdAt, closedAt))
}
Spacer()
VStack(alignment: .trailing) {
Text(connection.inboundType + "/" + connection.inbound)
Text(connection.chain.reversed().joined(separator: "/"))
}
} else {
VStack(alignment: .leading) {
Text("\(LibboxFormatBytes(connection.upload))/s")
Text("\(LibboxFormatBytes(connection.download))/s")
}
.font(.caption2)
VStack(alignment: .leading) {
Text(LibboxFormatBytes(connection.uploadTotal))
Text(LibboxFormatBytes(connection.downloadTotal))
}
Spacer()
VStack(alignment: .trailing) {
Text(connection.inboundType + "/" + connection.inbound)
Text(connection.chain.reversed().joined(separator: "/"))
}
}
}
.font(.caption2.monospaced())
}
}
.foregroundColor(.textColor)
#if !os(tvOS)
.padding(EdgeInsets(top: 10, leading: 13, bottom: 10, trailing: 13))
.background(backgroundColor)
.cornerRadius(10)
#endif
}
.background(.clear)
}
#if !os(tvOS)
.buttonStyle(.borderless)
#endif
.alertBinding($alert)
.contextMenu {
if connection.closedAt == nil {
Button("Close", role: .destructive) {
Task {
await closeConnection()
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private var backgroundColor: Color {
#if os(iOS)
return Color(uiColor: .secondarySystemGroupedBackground)
#elseif os(macOS)
return Color(nsColor: .textBackgroundColor)
#elseif os(tvOS)
return Color.black
#endif
}
private nonisolated func closeConnection() async {
do {
try LibboxNewStandaloneCommandClient()!.closeConnection(connection.id)
} catch {
await MainActor.run {
alert = Alert(error)
}
}
}
}