Skip to content

Commit 88ae5f9

Browse files
committed
feat: include ping and network stats on status tooltip
1 parent 170b399 commit 88ae5f9

File tree

10 files changed

+337
-11
lines changed

10 files changed

+337
-11
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/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: 162 additions & 5 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,90 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
1842
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
1943
}
2044

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

90+
extension TimeInterval {
91+
var prettyPrintMs: String {
92+
Measurement(value: self * 1000, unit: UnitDuration.milliseconds)
93+
.formatted(.measurement(width: .abbreviated,
94+
numberFormatStyle: .number.precision(.fractionLength(2))))
95+
}
96+
}
97+
98+
struct LastPing: Equatable, Hashable {
99+
let latency: TimeInterval
100+
let didP2p: Bool
101+
let preferredDerp: String
102+
let preferredDerpLatency: TimeInterval?
103+
}
104+
24105
enum AgentStatus: Int, Equatable, Comparable {
25106
case okay = 0
26-
case warn = 1
27-
case error = 2
28-
case off = 3
107+
case connecting = 1
108+
case warn = 2
109+
case error = 3
110+
case off = 4
111+
112+
public var description: String {
113+
switch self {
114+
case .okay: "Connected"
115+
case .connecting: "Connecting..."
116+
case .warn: "Connected, but with high latency" // Currently unused
117+
case .error: "Could not establish a connection to the agent. Retrying..."
118+
case .off: "Offline"
119+
}
120+
}
29121

30122
public var color: Color {
31123
switch self {
32124
case .okay: .green
33125
case .warn: .yellow
34126
case .error: .red
35127
case .off: .secondary
128+
case .connecting: .yellow
36129
}
37130
}
38131

@@ -87,14 +180,27 @@ struct VPNMenuState {
87180
workspace.agents.insert(id)
88181
workspaces[wsID] = workspace
89182

183+
var lastPing: LastPing?
184+
if agent.hasLastPing {
185+
lastPing = LastPing(
186+
latency: agent.lastPing.latency.timeInterval,
187+
didP2p: agent.lastPing.didP2P,
188+
preferredDerp: agent.lastPing.preferredDerp,
189+
preferredDerpLatency:
190+
agent.lastPing.hasPreferredDerpLatency
191+
? agent.lastPing.preferredDerpLatency.timeInterval
192+
: nil
193+
)
194+
}
90195
agents[id] = Agent(
91196
id: id,
92197
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,
198+
status: agent.status,
95199
hosts: nonEmptyHosts,
96200
wsName: workspace.name,
97201
wsID: wsID,
202+
lastPing: lastPing,
203+
lastHandshake: agent.lastHandshake.maybeDate,
98204
// Hosts arrive sorted by length, the shortest looks best in the UI.
99205
primaryHost: nonEmptyHosts.first!
100206
)
@@ -154,3 +260,54 @@ struct VPNMenuState {
154260
workspaces.removeAll()
155261
}
156262
}
263+
264+
extension Date {
265+
var relativeTimeString: String {
266+
let formatter = RelativeDateTimeFormatter()
267+
formatter.unitsStyle = .full
268+
if Date.now.timeIntervalSince(self) < 1.0 {
269+
// Instead of showing "in 0 seconds"
270+
return "Just now"
271+
}
272+
return formatter.localizedString(for: self, relativeTo: Date.now)
273+
}
274+
}
275+
276+
extension SwiftProtobuf.Google_Protobuf_Timestamp {
277+
var maybeDate: Date? {
278+
guard seconds > 0 else { return nil }
279+
return date
280+
}
281+
}
282+
283+
extension Vpn_Agent {
284+
var healthyLastHandshakeMin: Date {
285+
Date.now.addingTimeInterval(-500) // 5 minutes ago
286+
}
287+
288+
var healthyPingMax: TimeInterval { 0.15 } // 150ms
289+
290+
var status: AgentStatus {
291+
guard let lastHandshake = lastHandshake.maybeDate else {
292+
// Initially the handshake is missing
293+
return .connecting
294+
}
295+
296+
return if lastHandshake < healthyLastHandshakeMin {
297+
// If last handshake was not within the last five minutes, the agent
298+
// is potentially unhealthy.
299+
.error
300+
} else if hasLastPing, lastPing.latency.timeInterval < healthyPingMax {
301+
// If latency is less than 150ms
302+
.okay
303+
} else if hasLastPing, lastPing.latency.timeInterval >= healthyPingMax {
304+
// if latency is greater than 150ms
305+
.warn
306+
} else {
307+
// No ping data, but we have a recent handshake.
308+
// We show green for backwards compatibility with old Coder
309+
// deployments.
310+
.okay
311+
}
312+
}
313+
}

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)

Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ struct VPNMenuStateTests {
1818
$0.workspaceID = workspaceID.uuidData
1919
$0.name = "dev"
2020
$0.lastHandshake = .init(date: Date.now)
21+
$0.lastPing = .with {
22+
$0.latency = .init(floatLiteral: 0.05)
23+
}
2124
$0.fqdn = ["foo.coder"]
2225
}
2326

@@ -72,6 +75,29 @@ struct VPNMenuStateTests {
7275
#expect(state.workspaces[workspaceID] == nil)
7376
}
7477

78+
@Test
79+
mutating func testUpsertAgent_poorConnection() async throws {
80+
let agentID = UUID()
81+
let workspaceID = UUID()
82+
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
83+
84+
let agent = Vpn_Agent.with {
85+
$0.id = agentID.uuidData
86+
$0.workspaceID = workspaceID.uuidData
87+
$0.name = "agent1"
88+
$0.lastHandshake = .init(date: Date.now)
89+
$0.lastPing = .with {
90+
$0.latency = .init(seconds: 1)
91+
}
92+
$0.fqdn = ["foo.coder"]
93+
}
94+
95+
state.upsertAgent(agent)
96+
97+
let storedAgent = try #require(state.agents[agentID])
98+
#expect(storedAgent.status == .warn)
99+
}
100+
75101
@Test
76102
mutating func testUpsertAgent_unhealthyAgent() async throws {
77103
let agentID = UUID()
@@ -89,7 +115,7 @@ struct VPNMenuStateTests {
89115
state.upsertAgent(agent)
90116

91117
let storedAgent = try #require(state.agents[agentID])
92-
#expect(storedAgent.status == .warn)
118+
#expect(storedAgent.status == .error)
93119
}
94120

95121
@Test
@@ -114,6 +140,9 @@ struct VPNMenuStateTests {
114140
$0.workspaceID = workspaceID.uuidData
115141
$0.name = "agent1" // Same name as old agent
116142
$0.lastHandshake = .init(date: Date.now)
143+
$0.lastPing = .with {
144+
$0.latency = .init(floatLiteral: 0.05)
145+
}
117146
$0.fqdn = ["foo.coder"]
118147
}
119148

@@ -146,6 +175,9 @@ struct VPNMenuStateTests {
146175
$0.workspaceID = workspaceID.uuidData
147176
$0.name = "agent1"
148177
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-200))
178+
$0.lastPing = .with {
179+
$0.latency = .init(floatLiteral: 0.05)
180+
}
149181
$0.fqdn = ["foo.coder"]
150182
}
151183
state.upsertAgent(agent)

Coder-Desktop/VPN/Manager.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ actor Manager {
4040
dest: dest,
4141
urlSession: URLSession(configuration: sessionConfig)
4242
) { progress in
43-
// TODO: Debounce, somehow
4443
pushProgress(stage: .downloading, downloadProgress: progress)
4544
}
4645
} catch {
@@ -322,7 +321,7 @@ func writeVpnLog(_ log: Vpn_Log) {
322321
category: log.loggerNames.joined(separator: ".")
323322
)
324323
let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
325-
logger.log(level: level, "\(log.message, privacy: .public): \(fields, privacy: .public)")
324+
logger.log(level: level, "\(log.message, privacy: .public)\(fields.isEmpty ? "" : ": \(fields)", privacy: .public)")
326325
}
327326

328327
private func removeQuarantine(_ dest: URL) async throws(ManagerError) {

Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,6 @@ public extension MutagenDaemon {
4747
}
4848
}
4949
do {
50-
// The first creation will need to transfer the agent binary
51-
// TODO: Because this is pretty long, we should show progress updates
52-
// using the prompter messages
5350
_ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout * 4)))
5451
} catch {
5552
throw .grpcFailure(error)

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