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:
Daniel Redetzke
2026-10-03 19:21:10 +03:00
parent f31bb360c9
commit 37ab26b415
15 changed files with 707 additions and 21 deletions
+10
View File
@@ -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" {
+39
View File
@@ -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 {
+73
View File
@@ -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)
}
+12 -1
View File
@@ -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 }
}