Skip to content

feat: include ping and network stats on status tooltip #181

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 9, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
@@ -84,6 +84,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

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

8 changes: 4 additions & 4 deletions Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
@@ -5,21 +5,21 @@ import SwiftUI
final class PreviewVPN: Coder_Desktop.VPNService {
@Published var state: Coder_Desktop.VPNServiceState = .connected
@Published var menuState: VPNMenuState = .init(agents: [
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
UUID(): Agent(id: UUID(), name: "dev", status: .no_recent_handshake, hosts: ["asdf.coder"], wsName: "dogfood2",
wsID: UUID(), primaryHost: "asdf.coder"),
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
UUID(): Agent(id: UUID(), name: "dev", status: .high_latency, hosts: ["asdf.coder"], wsName: "opensrc",
wsID: UUID(), primaryHost: "asdf.coder"),
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
wsID: UUID(), primaryHost: "asdf.coder"),
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
wsID: UUID(), primaryHost: "asdf.coder"),
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
UUID(): Agent(id: UUID(), name: "dev", status: .no_recent_handshake, hosts: ["asdf.coder"], wsName: "dogfood2",
wsID: UUID(), primaryHost: "asdf.coder"),
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
wsName: "testing-a-very-long-name", wsID: UUID(), primaryHost: "asdf.coder"),
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
UUID(): Agent(id: UUID(), name: "dev", status: .high_latency, hosts: ["asdf.coder"], wsName: "opensrc",
wsID: UUID(), primaryHost: "asdf.coder"),
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
wsID: UUID(), primaryHost: "asdf.coder"),
1 change: 1 addition & 0 deletions Coder-Desktop/Coder-Desktop/Theme.swift
Original file line number Diff line number Diff line change
@@ -15,6 +15,7 @@ enum Theme {

enum Animation {
static let collapsibleDuration = 0.2
static let tooltipDelay: Int = 250 // milliseconds
}

static let defaultVisibleAgents = 5
170 changes: 163 additions & 7 deletions Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import SwiftProtobuf
import SwiftUI
import VPNLib

@@ -9,6 +10,29 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
let hosts: [String]
let wsName: String
let wsID: UUID
let lastPing: LastPing?
let lastHandshake: Date?

init(id: UUID,
name: String,
status: AgentStatus,
hosts: [String],
wsName: String,
wsID: UUID,
lastPing: LastPing? = nil,
lastHandshake: Date? = nil,
primaryHost: String)
{
self.id = id
self.name = name
self.status = status
self.hosts = hosts
self.wsName = wsName
self.wsID = wsID
self.lastPing = lastPing
self.lastHandshake = lastHandshake
self.primaryHost = primaryHost
}

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

var statusString: String {
switch status {
case .okay, .high_latency:
break
default:
return status.description
}

guard let lastPing else {
// Either:
// - Old coder deployment
// - We haven't received any pings yet
return status.description
}

let highLatencyWarning = status == .high_latency ? "(High latency)" : ""

var str: String
if lastPing.didP2p {
str = """
You're connected peer-to-peer. \(highLatencyWarning)
You ↔ \(lastPing.latency.prettyPrintMs)\(wsName)
"""
} else {
str = """
You're connected through a DERP relay. \(highLatencyWarning)
We'll switch over to peer-to-peer when available.
Total latency: \(lastPing.latency.prettyPrintMs)
"""
// We're not guranteed to have the preferred DERP latency
if let preferredDerpLatency = lastPing.preferredDerpLatency {
str += "\nYou ↔ \(lastPing.preferredDerp): \(preferredDerpLatency.prettyPrintMs)"
let derpToWorkspaceEstLatency = lastPing.latency - preferredDerpLatency
// We're not guaranteed the preferred derp latency is less than
// the total, as they might have been recorded at slightly
// different times, and we don't want to show a negative value.
if derpToWorkspaceEstLatency > 0 {
str += "\n\(lastPing.preferredDerp)\(wsName): \(derpToWorkspaceEstLatency.prettyPrintMs)"
}
}
}
str += "\n\nLast handshake: \(lastHandshake?.relativeTimeString ?? "Unknown")"
return str
}

let primaryHost: String
}

extension TimeInterval {
var prettyPrintMs: String {
let milliseconds = self * 1000
return "\(milliseconds.formatted(.number.precision(.fractionLength(2)))) ms"
}
}

struct LastPing: Equatable, Hashable {
let latency: TimeInterval
let didP2p: Bool
let preferredDerp: String
let preferredDerpLatency: TimeInterval?
}

enum AgentStatus: Int, Equatable, Comparable {
case okay = 0
case warn = 1
case error = 2
case off = 3
case connecting = 1
case high_latency = 2
case no_recent_handshake = 3
case off = 4

public var description: String {
switch self {
case .okay: "Connected"
case .connecting: "Connecting..."
case .high_latency: "Connected, but with high latency" // Message currently unused
case .no_recent_handshake: "Could not establish a connection to the agent. Retrying..."
case .off: "Offline"
}
}

public var color: Color {
switch self {
case .okay: .green
case .warn: .yellow
case .error: .red
case .high_latency: .yellow
case .no_recent_handshake: .red
case .off: .secondary
case .connecting: .yellow
}
}

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

var lastPing: LastPing?
if agent.hasLastPing {
lastPing = LastPing(
latency: agent.lastPing.latency.timeInterval,
didP2p: agent.lastPing.didP2P,
preferredDerp: agent.lastPing.preferredDerp,
preferredDerpLatency:
agent.lastPing.hasPreferredDerpLatency
? agent.lastPing.preferredDerpLatency.timeInterval
: nil
)
}
agents[id] = Agent(
id: id,
name: agent.name,
// If last handshake was not within last five minutes, the agent is unhealthy
status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn,
status: agent.status,
hosts: nonEmptyHosts,
wsName: workspace.name,
wsID: wsID,
lastPing: lastPing,
lastHandshake: agent.lastHandshake.maybeDate,
// Hosts arrive sorted by length, the shortest looks best in the UI.
primaryHost: nonEmptyHosts.first!
)
@@ -154,3 +264,49 @@ struct VPNMenuState {
workspaces.removeAll()
}
}

extension Date {
var relativeTimeString: String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .full
if Date.now.timeIntervalSince(self) < 1.0 {
// Instead of showing "in 0 seconds"
return "Just now"
}
return formatter.localizedString(for: self, relativeTo: Date.now)
}
}

extension SwiftProtobuf.Google_Protobuf_Timestamp {
var maybeDate: Date? {
guard seconds > 0 else { return nil }
return date
}
}

extension Vpn_Agent {
var healthyLastHandshakeMin: Date {
Date.now.addingTimeInterval(-300) // 5 minutes ago
}

var healthyPingMax: TimeInterval { 0.15 } // 150ms

var status: AgentStatus {
// Initially the handshake is missing
guard let lastHandshake = lastHandshake.maybeDate else {
return .connecting
}
// If last handshake was not within the last five minutes, the agent
// is potentially unhealthy.
guard lastHandshake >= healthyLastHandshakeMin else {
return .no_recent_handshake
}
// No ping data, but we have a recent handshake.
// We show green for backwards compatibility with old Coder
// deployments.
guard hasLastPing else {
return .okay
}
return lastPing.latency.timeInterval < healthyPingMax ? .okay : .high_latency
}
}
8 changes: 8 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
Original file line number Diff line number Diff line change
@@ -21,6 +21,13 @@ enum VPNMenuItem: Equatable, Comparable, Identifiable {
}
}

var statusString: String {
switch self {
case let .agent(agent): agent.statusString
case .offlineWorkspace: status.description
}
}

var id: UUID {
switch self {
case let .agent(agent): agent.id
@@ -224,6 +231,7 @@ struct MenuItemIcons: View {
StatusDot(color: item.status.color)
.padding(.trailing, 3)
.padding(.top, 1)
.help(item.statusString)
MenuItemIconButton(systemName: "doc.on.doc", action: copyToClipboard)
.font(.system(size: 9))
.symbolVariant(.fill)
1 change: 1 addition & 0 deletions Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@ struct AgentsTests {
hosts: ["a\($0).coder"],
wsName: "ws\($0)",
wsID: UUID(),
lastPing: nil,
primaryHost: "a\($0).coder"
)
return (agent.id, agent)
63 changes: 62 additions & 1 deletion Coder-Desktop/Coder-DesktopTests/VPNMenuStateTests.swift
Original file line number Diff line number Diff line change
@@ -18,6 +18,10 @@ struct VPNMenuStateTests {
$0.workspaceID = workspaceID.uuidData
$0.name = "dev"
$0.lastHandshake = .init(date: Date.now)
$0.lastPing = .with {
$0.latency = .init(floatLiteral: 0.05)
$0.didP2P = true
}
$0.fqdn = ["foo.coder"]
}

@@ -29,6 +33,9 @@ struct VPNMenuStateTests {
#expect(storedAgent.wsName == "foo")
#expect(storedAgent.primaryHost == "foo.coder")
#expect(storedAgent.status == .okay)
#expect(storedAgent.statusString.contains("You're connected peer-to-peer."))
#expect(storedAgent.statusString.contains("You ↔ 50.00 ms ↔ foo"))
#expect(storedAgent.statusString.contains("Last handshake: Just now"))
}

@Test
@@ -72,6 +79,49 @@ struct VPNMenuStateTests {
#expect(state.workspaces[workspaceID] == nil)
}

@Test
mutating func testUpsertAgent_poorConnection() async throws {
let agentID = UUID()
let workspaceID = UUID()
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })

let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init(date: Date.now)
$0.lastPing = .with {
$0.latency = .init(seconds: 1)
}
$0.fqdn = ["foo.coder"]
}

state.upsertAgent(agent)

let storedAgent = try #require(state.agents[agentID])
#expect(storedAgent.status == .high_latency)
}

@Test
mutating func testUpsertAgent_connecting() async throws {
let agentID = UUID()
let workspaceID = UUID()
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })

let agent = Vpn_Agent.with {
$0.id = agentID.uuidData
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init()
$0.fqdn = ["foo.coder"]
}

state.upsertAgent(agent)

let storedAgent = try #require(state.agents[agentID])
#expect(storedAgent.status == .connecting)
}

@Test
mutating func testUpsertAgent_unhealthyAgent() async throws {
let agentID = UUID()
@@ -89,7 +139,7 @@ struct VPNMenuStateTests {
state.upsertAgent(agent)

let storedAgent = try #require(state.agents[agentID])
#expect(storedAgent.status == .warn)
#expect(storedAgent.status == .no_recent_handshake)
}

@Test
@@ -114,6 +164,9 @@ struct VPNMenuStateTests {
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1" // Same name as old agent
$0.lastHandshake = .init(date: Date.now)
$0.lastPing = .with {
$0.latency = .init(floatLiteral: 0.05)
}
$0.fqdn = ["foo.coder"]
}

@@ -146,6 +199,10 @@ struct VPNMenuStateTests {
$0.workspaceID = workspaceID.uuidData
$0.name = "agent1"
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-200))
$0.lastPing = .with {
$0.didP2P = false
$0.latency = .init(floatLiteral: 0.05)
}
$0.fqdn = ["foo.coder"]
}
state.upsertAgent(agent)
@@ -155,6 +212,10 @@ struct VPNMenuStateTests {
#expect(output[0].id == agentID)
#expect(output[0].wsName == "foo")
#expect(output[0].status == .okay)
let storedAgentFromSort = try #require(state.agents[agentID])
#expect(storedAgentFromSort.statusString.contains("You're connected through a DERP relay."))
#expect(storedAgentFromSort.statusString.contains("Total latency: 50.00 ms"))
#expect(storedAgentFromSort.statusString.contains("Last handshake: 3 minutes ago"))
}

@Test
3 changes: 1 addition & 2 deletions Coder-Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
@@ -40,7 +40,6 @@ actor Manager {
dest: dest,
urlSession: URLSession(configuration: sessionConfig)
) { progress in
// TODO: Debounce, somehow
pushProgress(stage: .downloading, downloadProgress: progress)
}
} catch {
@@ -322,7 +321,7 @@ func writeVpnLog(_ log: Vpn_Log) {
category: log.loggerNames.joined(separator: ".")
)
let fields = log.fields.map { "\($0.name): \($0.value)" }.joined(separator: ", ")
logger.log(level: level, "\(log.message, privacy: .public): \(fields, privacy: .public)")
logger.log(level: level, "\(log.message, privacy: .public)\(fields.isEmpty ? "" : ": \(fields)", privacy: .public)")
}

private func removeQuarantine(_ dest: URL) async throws(ManagerError) {
3 changes: 0 additions & 3 deletions Coder-Desktop/VPNLib/FileSync/FileSyncManagement.swift
Original file line number Diff line number Diff line change
@@ -47,9 +47,6 @@ public extension MutagenDaemon {
}
}
do {
// The first creation will need to transfer the agent binary
// TODO: Because this is pretty long, we should show progress updates
// using the prompter messages
_ = try await client!.sync.create(req, callOptions: .init(timeLimit: .timeout(sessionMgmtReqTimeout * 4)))
} catch {
throw .grpcFailure(error)
112 changes: 112 additions & 0 deletions Coder-Desktop/VPNLib/vpn.pb.swift

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions Coder-Desktop/VPNLib/vpn.proto
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ option go_package = "github.com/coder/coder/v2/vpn";
option csharp_namespace = "Coder.Desktop.Vpn.Proto";

import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";

package vpn;

@@ -130,6 +131,21 @@ message Agent {
// last_handshake is the primary indicator of whether we are connected to a peer. Zero value or
// anything longer than 5 minutes ago means there is a problem.
google.protobuf.Timestamp last_handshake = 6;
// If unset, a successful ping has not yet been made.
optional LastPing last_ping = 7;
}

message LastPing {
// latency is the RTT of the ping to the agent.
google.protobuf.Duration latency = 1;
// did_p2p indicates whether the ping was sent P2P, or over DERP.
bool did_p2p = 2;
// preferred_derp is the human readable name of the preferred DERP region,
// or the region used for the last ping, if it was sent over DERP.
string preferred_derp = 3;
// preferred_derp_latency is the last known latency to the preferred DERP
// region. Unset if the region does not appear in the DERP map.
optional google.protobuf.Duration preferred_derp_latency = 4;
}

// NetworkSettingsRequest is based on