Skip to content

Commit f8a5ca5

Browse files
feat: include ping and network stats on status tooltip (#181)
Closes #64. ![Screenshot 2025-06-06 at 4 03 59 pm](https://github.com/user-attachments/assets/0b844e2f-4f09-4137-b937-a16a5db3b6ac) ![Screenshot 2025-06-06 at 4 03 51 pm](https://github.com/user-attachments/assets/1ac021aa-7761-49a3-abad-a286271a794a)
1 parent 170b399 commit f8a5ca5

File tree

11 files changed

+371
-17
lines changed

11 files changed

+371
-17
lines changed

Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
8484
}
8585

8686
func applicationDidFinishLaunching(_: Notification) {
87+
// We have important file sync and network info behind tooltips,
88+
// so the default delay is too long.
89+
UserDefaults.standard.setValue(Theme.Animation.tooltipDelay, forKey: "NSInitialToolTipDelay")
8790
// Init SVG loader
8891
SDImageCodersManager.shared.addCoder(SDImageSVGCoder.shared)
8992

Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@ import SwiftUI
55
final class PreviewVPN: Coder_Desktop.VPNService {
66
@Published var state: Coder_Desktop.VPNServiceState = .connected
77
@Published var menuState: VPNMenuState = .init(agents: [
8-
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
8+
UUID(): Agent(id: UUID(), name: "dev", status: .no_recent_handshake, hosts: ["asdf.coder"], wsName: "dogfood2",
99
wsID: UUID(), primaryHost: "asdf.coder"),
1010
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
1111
wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
12-
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
12+
UUID(): Agent(id: UUID(), name: "dev", status: .high_latency, hosts: ["asdf.coder"], wsName: "opensrc",
1313
wsID: UUID(), primaryHost: "asdf.coder"),
1414
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
1515
wsID: UUID(), primaryHost: "asdf.coder"),
1616
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
1717
wsID: UUID(), primaryHost: "asdf.coder"),
18-
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
18+
UUID(): Agent(id: UUID(), name: "dev", status: .no_recent_handshake, hosts: ["asdf.coder"], wsName: "dogfood2",
1919
wsID: UUID(), primaryHost: "asdf.coder"),
2020
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
2121
wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
22-
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
22+
UUID(): Agent(id: UUID(), name: "dev", status: .high_latency, hosts: ["asdf.coder"], wsName: "opensrc",
2323
wsID: UUID(), primaryHost: "asdf.coder"),
2424
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
2525
wsID: UUID(), primaryHost: "asdf.coder"),

Coder-Desktop/Coder-Desktop/Theme.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ enum Theme {
1515

1616
enum Animation {
1717
static let collapsibleDuration = 0.2
18+
static let tooltipDelay: Int = 250 // milliseconds
1819
}
1920

2021
static let defaultVisibleAgents = 5

Coder-Desktop/Coder-Desktop/VPN/MenuState.swift

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import SwiftProtobuf
23
import SwiftUI
34
import VPNLib
45

@@ -9,6 +10,29 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
910
let hosts: [String]
1011
let wsName: String
1112
let wsID: UUID
13+
let lastPing: LastPing?
14+
let lastHandshake: Date?
15+
16+
init(id: UUID,
17+
name: String,
18+
status: AgentStatus,
19+
hosts: [String],
20+
wsName: String,
21+
wsID: UUID,
22+
lastPing: LastPing? = nil,
23+
lastHandshake: Date? = nil,
24+
primaryHost: String)
25+
{
26+
self.id = id
27+
self.name = name
28+
self.status = status
29+
self.hosts = hosts
30+
self.wsName = wsName
31+
self.wsID = wsID
32+
self.lastPing = lastPing
33+
self.lastHandshake = lastHandshake
34+
self.primaryHost = primaryHost
35+
}
1236

1337
// Agents are sorted by status, and then by name
1438
static func < (lhs: Agent, rhs: Agent) -> Bool {
@@ -18,21 +42,94 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
1842
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
1943
}
2044

45+
var statusString: String {
46+
switch status {
47+
case .okay, .high_latency:
48+
break
49+
default:
50+
return status.description
51+
}
52+
53+
guard let lastPing else {
54+
// Either:
55+
// - Old coder deployment
56+
// - We haven't received any pings yet
57+
return status.description
58+
}
59+
60+
let highLatencyWarning = status == .high_latency ? "(High latency)" : ""
61+
62+
var str: String
63+
if lastPing.didP2p {
64+
str = """
65+
You're connected peer-to-peer. \(highLatencyWarning)
66+
67+
You ↔ \(lastPing.latency.prettyPrintMs)\(wsName)
68+
"""
69+
} else {
70+
str = """
71+
You're connected through a DERP relay. \(highLatencyWarning)
72+
We'll switch over to peer-to-peer when available.
73+
74+
Total latency: \(lastPing.latency.prettyPrintMs)
75+
"""
76+
// We're not guranteed to have the preferred DERP latency
77+
if let preferredDerpLatency = lastPing.preferredDerpLatency {
78+
str += "\nYou ↔ \(lastPing.preferredDerp): \(preferredDerpLatency.prettyPrintMs)"
79+
let derpToWorkspaceEstLatency = lastPing.latency - preferredDerpLatency
80+
// We're not guaranteed the preferred derp latency is less than
81+
// the total, as they might have been recorded at slightly
82+
// different times, and we don't want to show a negative value.
83+
if derpToWorkspaceEstLatency > 0 {
84+
str += "\n\(lastPing.preferredDerp)\(wsName): \(derpToWorkspaceEstLatency.prettyPrintMs)"
85+
}
86+
}
87+
}
88+
str += "\n\nLast handshake: \(lastHandshake?.relativeTimeString ?? "Unknown")"
89+
return str
90+
}
91+
2192
let primaryHost: String
2293
}
2394

95+
extension TimeInterval {
96+
var prettyPrintMs: String {
97+
let milliseconds = self * 1000
98+
return "\(milliseconds.formatted(.number.precision(.fractionLength(2)))) ms"
99+
}
100+
}
101+
102+
struct LastPing: Equatable, Hashable {
103+
let latency: TimeInterval
104+
let didP2p: Bool
105+
let preferredDerp: String
106+
let preferredDerpLatency: TimeInterval?
107+
}
108+
24109
enum AgentStatus: Int, Equatable, Comparable {
25110
case okay = 0
26-
case warn = 1
27-
case error = 2
28-
case off = 3
111+
case connecting = 1
112+
case high_latency = 2
113+
case no_recent_handshake = 3
114+
case off = 4
115+
116+
public var description: String {
117+
switch self {
118+
case .okay: "Connected"
119+
case .connecting: "Connecting..."
120+
case .high_latency: "Connected, but with high latency" // Message currently unused
121+
case .no_recent_handshake: "Could not establish a connection to the agent. Retrying..."
122+
case .off: "Offline"
123+
}
124+
}
29125

30126
public var color: Color {
31127
switch self {
32128
case .okay: .green
33-
case .warn: .yellow
34-
case .error: .red
129+
case .high_latency: .yellow
130+
case .no_recent_handshake: .red
35131
case .off: .secondary
132+
case .connecting: .yellow
36133
}
37134
}
38135

@@ -87,14 +184,27 @@ struct VPNMenuState {
87184
workspace.agents.insert(id)
88185
workspaces[wsID] = workspace
89186

187+
var lastPing: LastPing?
188+
if agent.hasLastPing {
189+
lastPing = LastPing(
190+
latency: agent.lastPing.latency.timeInterval,
191+
didP2p: agent.lastPing.didP2P,
192+
preferredDerp: agent.lastPing.preferredDerp,
193+
preferredDerpLatency:
194+
agent.lastPing.hasPreferredDerpLatency
195+
? agent.lastPing.preferredDerpLatency.timeInterval
196+
: nil
197+
)
198+
}
90199
agents[id] = Agent(
91200
id: id,
92201
name: agent.name,
93-
// If last handshake was not within last five minutes, the agent is unhealthy
94-
status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn,
202+
status: agent.status,
95203
hosts: nonEmptyHosts,
96204
wsName: workspace.name,
97205
wsID: wsID,
206+
lastPing: lastPing,
207+
lastHandshake: agent.lastHandshake.maybeDate,
98208
// Hosts arrive sorted by length, the shortest looks best in the UI.
99209
primaryHost: nonEmptyHosts.first!
100210
)
@@ -154,3 +264,49 @@ struct VPNMenuState {
154264
workspaces.removeAll()
155265
}
156266
}
267+
268+
extension Date {
269+
var relativeTimeString: String {
270+
let formatter = RelativeDateTimeFormatter()
271+
formatter.unitsStyle = .full
272+
if Date.now.timeIntervalSince(self) < 1.0 {
273+
// Instead of showing "in 0 seconds"
274+
return "Just now"
275+
}
276+
return formatter.localizedString(for: self, relativeTo: Date.now)
277+
}
278+
}
279+
280+
extension SwiftProtobuf.Google_Protobuf_Timestamp {
281+
var maybeDate: Date? {
282+
guard seconds > 0 else { return nil }
283+
return date
284+
}
285+
}
286+
287+
extension Vpn_Agent {
288+
var healthyLastHandshakeMin: Date {
289+
Date.now.addingTimeInterval(-300) // 5 minutes ago
290+
}
291+
292+
var healthyPingMax: TimeInterval { 0.15 } // 150ms
293+
294+
var status: AgentStatus {
295+
// Initially the handshake is missing
296+
guard let lastHandshake = lastHandshake.maybeDate else {
297+
return .connecting
298+
}
299+
// If last handshake was not within the last five minutes, the agent
300+
// is potentially unhealthy.
301+
guard lastHandshake >= healthyLastHandshakeMin else {
302+
return .no_recent_handshake
303+
}
304+
// No ping data, but we have a recent handshake.
305+
// We show green for backwards compatibility with old Coder
306+
// deployments.
307+
guard hasLastPing else {
308+
return .okay
309+
}
310+
return lastPing.latency.timeInterval < healthyPingMax ? .okay : .high_latency
311+
}
312+
}

Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
2121
}
2222
}
2323

24+
var statusString: String {
25+
switch self {
26+
case let .agent(agent): agent.statusString
27+
case .offlineWorkspace: status.description
28+
}
29+
}
30+
2431
var id: UUID {
2532
switch self {
2633
case let .agent(agent): agent.id
@@ -224,6 +231,7 @@ struct MenuItemIcons: View {
224231
StatusDot(color: item.status.color)
225232
.padding(.trailing, 3)
226233
.padding(.top, 1)
234+
.help(item.statusString)
227235
MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
228236
.font(.system(size: 9))
229237
.symbolVariant(.fill)

Coder-Desktop/Coder-DesktopTests/AgentsTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ struct AgentsTests {
2828
hosts: ["a\($0).coder"],
2929
wsName: "ws\($0)",
3030
wsID: UUID(),
31+
lastPing: nil,
3132
primaryHost: "a\($0).coder"
3233
)
3334
return (agent.id, agent)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy