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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
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
Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions Coder-Desktop/Coder-Desktop/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ enum Theme {

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

static let defaultVisibleAgents = 5
Expand Down
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

Expand All @@ -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 {
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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!
)
Expand Down Expand Up @@ -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
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions Coder-Desktop/Coder-DesktopTests/AgentsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading