Add connection history per peer with country and network lookup
- The stats sampler records sessions per peer: start, end, address and
traffic. A session ends when the peer goes quiet or is disabled; a new
one starts when the device changes networks. Stored in stats.json and
kept as long as the daily traffic history (max 1000 per peer).
- Country and network operator come from the free DB-IP Lite databases
(CC BY 4.0), downloaded monthly and looked up locally, so peer
addresses never leave the server. Settings → Data retention can switch
this off, which deletes the databases.
- API: GET /peers/{id}/sessions; peer stats include the current location;
settings include the database status.
- Web UI and iOS app: connection history card, location line, country
code in the peer list (web), switch in data retention.
Claude-Session: https://claude.ai/code/session_01RAnLbyQZ5ZTA7KqwXP98nw
This commit is contained in:
@@ -33,6 +33,16 @@ func fmtDate(_ date: Date?) -> String {
|
||||
return date.formatted(date: .abbreviated, time: .omitted)
|
||||
}
|
||||
|
||||
/// "35 min", "2 h 5 min", "3 days".
|
||||
func fmtDuration(_ seconds: Int64) -> String {
|
||||
if seconds < 60 { return "under 1 min" }
|
||||
let m = Int((Double(seconds) / 60).rounded())
|
||||
if m < 60 { return "\(m) min" }
|
||||
let h = m / 60
|
||||
if h < 48 { return "\(h) h \(m % 60) min" }
|
||||
return "\(Int((Double(h) / 24).rounded())) days"
|
||||
}
|
||||
|
||||
/// Label of a chart point: "3 h ago" for hours, "Sat 3 Oct" for days.
|
||||
func pointLabel(_ p: StatPoint, range: String) -> String {
|
||||
if range == "24h" {
|
||||
|
||||
@@ -61,6 +61,38 @@ nonisolated struct PeerStats: Decodable, Hashable {
|
||||
let lastHandshake: Date?
|
||||
let endpoint: String
|
||||
let down24h, up24h, down30d, up30d, downTotal, upTotal: Int64
|
||||
let location: GeoInfo?
|
||||
}
|
||||
|
||||
/// Country and network of an address, from the server's DB-IP lookup.
|
||||
nonisolated struct GeoInfo: Decodable, Hashable {
|
||||
let country: String?
|
||||
let countryName: String?
|
||||
let asn: Int?
|
||||
let network: String?
|
||||
|
||||
/// "Germany · Deutsche Telekom AG" or "Local network".
|
||||
var label: String {
|
||||
[countryName ?? country, network].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of a peer's connection history.
|
||||
nonisolated struct ConnSession: Decodable, Identifiable, Hashable {
|
||||
let start: Date
|
||||
let end: Date
|
||||
let open: Bool
|
||||
let seconds: Int64
|
||||
let endpoint: String
|
||||
let ip: String
|
||||
let geo: GeoInfo?
|
||||
let down: Int64
|
||||
let up: Int64
|
||||
var id: String { "\(start.timeIntervalSince1970)-\(ip)" }
|
||||
}
|
||||
|
||||
nonisolated struct SessionsResponse: Decodable {
|
||||
let sessions: [ConnSession]
|
||||
}
|
||||
|
||||
nonisolated struct Peer: Decodable, Identifiable, Hashable {
|
||||
@@ -166,6 +198,12 @@ nonisolated struct LogSettings: Codable, Equatable {
|
||||
nonisolated struct StatsSettings: Codable, Equatable {
|
||||
var hourlyHours: Int
|
||||
var dailyDays: Int
|
||||
var geoip: Bool?
|
||||
}
|
||||
|
||||
nonisolated struct GeoStatus: Decodable {
|
||||
let enabled: Bool
|
||||
let updated: Date?
|
||||
}
|
||||
|
||||
nonisolated struct AppSettings: Decodable {
|
||||
@@ -175,6 +213,7 @@ nonisolated struct AppSettings: Decodable {
|
||||
var adminUsername: String
|
||||
var fingerprint: String
|
||||
var logPath: String
|
||||
var geo: GeoStatus?
|
||||
}
|
||||
|
||||
nonisolated struct SettingsResult: Decodable {
|
||||
|
||||
@@ -15,8 +15,11 @@ struct PeerDetailView: View {
|
||||
@State private var askKey = false
|
||||
@State private var deviceKey = ""
|
||||
@State private var editing = false
|
||||
@State private var sessions: [ConnSession] = []
|
||||
@State private var allSessions = false
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
if let error { Notice(text: error, isError: true) }
|
||||
@@ -24,6 +27,7 @@ struct PeerDetailView: View {
|
||||
header(p)
|
||||
traffic
|
||||
connection(p)
|
||||
history.id("history")
|
||||
clientConfig(p)
|
||||
settings(p)
|
||||
} else if error == nil {
|
||||
@@ -33,6 +37,13 @@ struct PeerDetailView: View {
|
||||
.padding(16)
|
||||
}
|
||||
.background(Color.gwGround)
|
||||
#if DEBUG
|
||||
// Development: `-scrollToHistory YES` for screenshots of the history.
|
||||
.task(id: sessions.count) {
|
||||
if UserDefaults.standard.bool(forKey: "scrollToHistory"), !sessions.isEmpty { proxy.scrollTo("history", anchor: .top) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.navigationTitle(peer?.name ?? "Peer")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -107,6 +118,7 @@ struct PeerDetailView: View {
|
||||
SectionTitle(text: "Connection")
|
||||
KV(key: "Tunnel address", value: p.ipv4 + "/32" + (p.ipv6.map { "\n" + $0 + "/128" } ?? ""), mono: true)
|
||||
KV(key: "Endpoint", value: p.stats.endpoint.isEmpty ? "–" : p.stats.endpoint, mono: true)
|
||||
KV(key: "Location", value: p.stats.location?.label.isEmpty == false ? p.stats.location!.label : "–")
|
||||
KV(key: "Latest handshake", value: ago(p.stats.lastHandshake))
|
||||
KV(key: "Public key", value: p.publicKey, mono: true)
|
||||
KV(key: "Preshared key", value: p.hasPresharedKey ? "Set" : "None")
|
||||
@@ -115,6 +127,66 @@ struct PeerDetailView: View {
|
||||
.card()
|
||||
}
|
||||
|
||||
private var history: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
SectionTitle(text: "Connection history")
|
||||
Text("Newest first. A new row starts when the device changes networks.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.gwText2)
|
||||
.padding(.bottom, 8)
|
||||
if sessions.isEmpty {
|
||||
Text("No connections recorded yet.").font(.footnote).foregroundStyle(Color.gwText2).padding(.vertical, 6)
|
||||
}
|
||||
let shown = allSessions ? sessions : Array(sessions.prefix(8))
|
||||
ForEach(shown) { se in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(se.start.formatted(.dateTime.day().month(.abbreviated).hour().minute()))
|
||||
.font(.subheadline.weight(.medium))
|
||||
if se.open {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(Color.gwGood).frame(width: 6, height: 6)
|
||||
Text("online")
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(Color.gwBadge, in: Capsule())
|
||||
}
|
||||
}
|
||||
Text(se.geo?.label.isEmpty == false ? se.geo!.label : "Unknown location")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(se.geo == nil ? Color.gwText2 : Color.gwText)
|
||||
Text(se.ip + " · " + fmtDuration(se.seconds))
|
||||
.font(.mono(.caption))
|
||||
.foregroundStyle(Color.gwText2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("↓ " + fmtBytes(se.down)).font(.footnote.monospacedDigit())
|
||||
Text("↑ " + fmtBytes(se.up)).font(.footnote.monospacedDigit()).foregroundStyle(Color.gwText2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.accessibilityElement(children: .combine)
|
||||
if se.id != shown.last?.id { Divider() }
|
||||
}
|
||||
if sessions.count > 8 {
|
||||
Button(allSessions ? "Show fewer" : "Show all \(sessions.count)") { allSessions.toggle() }
|
||||
.font(.footnote.weight(.medium))
|
||||
.padding(.top, 8)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Text("Country and network:")
|
||||
Link("IP Geolocation by DB-IP", destination: URL(string: "https://db-ip.com")!).underline()
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Color.gwText2)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
.card()
|
||||
}
|
||||
|
||||
private func clientConfig(_ p: Peer) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionTitle(text: "Client configuration")
|
||||
@@ -154,6 +226,7 @@ struct PeerDetailView: View {
|
||||
(peer, server) = try await (p, s)
|
||||
error = nil
|
||||
await loadStats()
|
||||
if let r: SessionsResponse = try? await api.get("/peers/\(peerID)/sessions?limit=100") { sessions = r.sessions }
|
||||
} catch {
|
||||
self.error = session.message(for: error)
|
||||
}
|
||||
|
||||
@@ -139,6 +139,12 @@ struct SettingsView: View {
|
||||
Picker("Daily traffic history", selection: s.dailyDays) {
|
||||
ForEach(options(Self.daily, current: stats!.dailyDays, unit: "days"), id: \.0) { Text($0.1).tag($0.0) }
|
||||
}
|
||||
Toggle(isOn: Binding(get: { stats!.geoip ?? true }, set: { stats!.geoip = $0 })) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Show country and network")
|
||||
Text(geoStatusText).font(.caption).foregroundStyle(Color.gwText2)
|
||||
}
|
||||
}
|
||||
Button("Save retention") {
|
||||
guard let old = settings, let log, let stats else { return }
|
||||
if log.maxFiles < old.log.maxFiles || stats.hourlyHours < old.stats.hourlyHours || stats.dailyDays < old.stats.dailyDays {
|
||||
@@ -151,7 +157,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Data retention")
|
||||
} footer: {
|
||||
Text("The log uses up to \(log!.maxSizeMB * (log!.maxFiles + 1)) MB on disk. All-time traffic totals are always kept. Applies immediately.")
|
||||
Text("The log uses up to \(log!.maxSizeMB * (log!.maxFiles + 1)) MB on disk. Connection history is kept as long as the daily traffic history; all-time totals are always kept. Country and network come from the free DB-IP Lite databases, downloaded monthly and looked up on the server only. Applies immediately.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +177,11 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var geoStatusText: String {
|
||||
guard let updated = settings?.geo?.updated else { return "Database not downloaded yet" }
|
||||
return "Database from \(fmtDate(updated))"
|
||||
}
|
||||
|
||||
private func options(_ presets: [(Int, String)], current: Int, unit: String) -> [(Int, String)] {
|
||||
presets.contains { $0.0 == current } ? presets : (presets + [(current, "\(current) \(unit)")]).sorted { $0.0 < $1.0 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user