c08fc277a9
A native macOS network monitor that renders live traffic as a top-down neon city: your Mac is downtown, remote orgs are glowing districts, and traffic is cars of light driving the roads. Architecture: - NetworkCityCore: swappable data layer behind a ConnectionSource protocol (nettop-backed today), plus pure/tested logic — traffic diffing, org classification (reverse-DNS + CIDR), traffic classes, hub-and-spoke expansion policy, label anti-overlap, and latency→distance layout. - NetworkCityApp: SwiftUI + SpriteKit city — auto-expanding districts, protocol-coloured cars, click-to-inspect panel, and latency-as-distance (districts glide to their measured RTT). - nettop-probe: CLI proof of the data layer. 44 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
8.0 KiB
Swift
201 lines
8.0 KiB
Swift
import SwiftUI
|
|
import SpriteKit
|
|
import NetworkCityCore
|
|
|
|
/// The SpriteKit city with a glassy stats HUD floated on top.
|
|
struct CityView: View {
|
|
@ObservedObject var controller: TrafficController
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
SpriteView(scene: controller.scene, options: [.ignoresSiblingOrder])
|
|
.ignoresSafeArea()
|
|
|
|
hud
|
|
.padding(14)
|
|
|
|
if let inspection = controller.selection {
|
|
inspector(inspection)
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
|
}
|
|
}
|
|
.animation(.easeOut(duration: 0.18), value: controller.selection?.id)
|
|
.onAppear { controller.start() }
|
|
}
|
|
|
|
// MARK: - Inspector panel
|
|
|
|
static func color(_ c: TrafficClass) -> Color {
|
|
switch c {
|
|
case .dns: return Color(red: 1.00, green: 0.82, blue: 0.25)
|
|
case .https: return Color(red: 0.22, green: 0.88, blue: 1.00)
|
|
case .http: return Color(red: 1.00, green: 0.48, blue: 0.24)
|
|
case .quic: return Color(red: 0.71, green: 0.48, blue: 1.00)
|
|
case .other: return Color(red: 0.55, green: 0.60, blue: 0.72)
|
|
}
|
|
}
|
|
|
|
private func inspector(_ insp: OrgInspection) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text(insp.title)
|
|
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(.white)
|
|
Spacer(minLength: 12)
|
|
Button { controller.deselect() } label: {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
HStack(spacing: 16) {
|
|
Text("▼ \(fmt(insp.downKBs))").foregroundStyle(.cyan)
|
|
Text("▲ \(fmt(insp.upKBs))").foregroundStyle(.orange)
|
|
Spacer(minLength: 4)
|
|
Text("\(insp.endpointCount) pts").foregroundStyle(.white.opacity(0.6))
|
|
}
|
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
|
|
|
if !insp.processes.isEmpty {
|
|
Text(insp.processes.joined(separator: ", "))
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.55))
|
|
.lineLimit(2)
|
|
}
|
|
|
|
Divider().overlay(.white.opacity(0.15))
|
|
|
|
Text("ENDPOINTS").font(.system(size: 9, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
ForEach(insp.endpoints) { ep in endpointRow(ep) }
|
|
}
|
|
}
|
|
.frame(maxHeight: 320)
|
|
}
|
|
.padding(14)
|
|
.frame(width: 290)
|
|
.background(.black.opacity(0.62), in: RoundedRectangle(cornerRadius: 12))
|
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.1)))
|
|
}
|
|
|
|
private func endpointRow(_ ep: EndpointInfo) -> some View {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Circle().fill(Self.color(ep.topClass)).frame(width: 7, height: 7)
|
|
.shadow(color: Self.color(ep.topClass), radius: 3)
|
|
.padding(.top, 3)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(ep.host)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
if let rdns = ep.rdns {
|
|
Text(rdns)
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.45))
|
|
.lineLimit(1).truncationMode(.middle)
|
|
}
|
|
if let rtt = ep.rttMs {
|
|
Text("\(Int(rtt)) ms")
|
|
.font(.system(size: 9, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.5))
|
|
}
|
|
}
|
|
Spacer(minLength: 6)
|
|
VStack(alignment: .trailing, spacing: 1) {
|
|
if ep.downKBs >= 0.1 { Text("▼\(fmt(ep.downKBs))").foregroundStyle(.cyan.opacity(0.85)) }
|
|
if ep.upKBs >= 0.1 { Text("▲\(fmt(ep.upKBs))").foregroundStyle(.orange.opacity(0.85)) }
|
|
if ep.downKBs < 0.1 && ep.upKBs < 0.1 { Text("idle").foregroundStyle(.white.opacity(0.3)) }
|
|
}
|
|
.font(.system(size: 9, weight: .semibold, design: .monospaced))
|
|
}
|
|
}
|
|
|
|
private var hud: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(controller.live ? Color.green : Color.orange)
|
|
.frame(width: 8, height: 8)
|
|
Text("NETWORKCITY")
|
|
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
}
|
|
Divider().frame(width: 168).overlay(.white.opacity(0.15))
|
|
stat("▼ down", controller.downKBs, .cyan)
|
|
stat("▲ up", controller.upKBs, .orange)
|
|
stat(label: "districts", value: "\(controller.districtCount)")
|
|
|
|
Divider().frame(width: 168).overlay(.white.opacity(0.15))
|
|
legend
|
|
|
|
Text(controller.live ? "live · color = protocol · motion = direction"
|
|
: "warming up…")
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.4))
|
|
.padding(.top, 2)
|
|
}
|
|
.padding(12)
|
|
.background(.black.opacity(0.45), in: RoundedRectangle(cornerRadius: 10))
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.white.opacity(0.08)))
|
|
}
|
|
|
|
private static let legendItems: [(String, Color)] = [
|
|
("DNS", Color(red: 1.00, green: 0.82, blue: 0.25)),
|
|
("HTTPS", Color(red: 0.22, green: 0.88, blue: 1.00)),
|
|
("HTTP", Color(red: 1.00, green: 0.48, blue: 0.24)),
|
|
("QUIC", Color(red: 0.71, green: 0.48, blue: 1.00)),
|
|
("Other", Color(red: 0.55, green: 0.60, blue: 0.72)),
|
|
]
|
|
|
|
private var legend: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(Self.legendItems, id: \.0) { name, color in
|
|
HStack(spacing: 8) {
|
|
Circle().fill(color).frame(width: 7, height: 7)
|
|
.shadow(color: color, radius: 3)
|
|
Text(name)
|
|
.font(.system(size: 11, design: .monospaced))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
}
|
|
}
|
|
}
|
|
.frame(width: 168, alignment: .leading)
|
|
}
|
|
|
|
private func stat(_ label: String, _ kbs: Double, _ color: Color) -> some View {
|
|
HStack {
|
|
Text(label)
|
|
.font(.system(size: 12, design: .monospaced))
|
|
.foregroundStyle(color.opacity(0.9))
|
|
Spacer(minLength: 16)
|
|
Text(format(kbs))
|
|
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
|
.foregroundStyle(.white)
|
|
}
|
|
.frame(width: 168)
|
|
}
|
|
|
|
private func stat(label: String, value: String) -> some View {
|
|
HStack {
|
|
Text(label).font(.system(size: 12, design: .monospaced)).foregroundStyle(.white.opacity(0.7))
|
|
Spacer(minLength: 16)
|
|
Text(value).font(.system(size: 12, weight: .semibold, design: .monospaced)).foregroundStyle(.white)
|
|
}
|
|
.frame(width: 168)
|
|
}
|
|
|
|
private func format(_ kbs: Double) -> String {
|
|
kbs >= 1024 ? String(format: "%.1f MB/s", kbs / 1024) : String(format: "%.0f KB/s", kbs)
|
|
}
|
|
|
|
/// Compact rate for the dense inspector rows.
|
|
private func fmt(_ kbs: Double) -> String {
|
|
kbs >= 1024 ? String(format: "%.1fM", kbs / 1024) : String(format: "%.0fK", kbs)
|
|
}
|
|
}
|