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)