Skip to content

Commit

Permalink
fix: display offline workspaces (#41)
Browse files Browse the repository at this point in the history
Redo of #39.
  • Loading branch information
ethanndickson authored Feb 12, 2025
1 parent 2bfe5bd commit 64b8d52
Show file tree
Hide file tree
Showing 16 changed files with 611 additions and 309 deletions.
3 changes: 2 additions & 1 deletion Coder Desktop/Coder Desktop/About.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI

enum About {
public static let repo: String = "https://github.com/coder/coder-desktop-macos"
private static var credits: NSAttributedString {
let coder = NSMutableAttributedString(
string: "Coder.com",
Expand All @@ -21,7 +22,7 @@ enum About {
string: "GitHub",
attributes: [
.foregroundColor: NSColor.labelColor,
.link: NSURL(string: "https://github.com/coder/coder-desktop-macos")!,
.link: NSURL(string: About.repo)!,
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
]
)
Expand Down
26 changes: 13 additions & 13 deletions Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,29 @@ import SwiftUI

@MainActor
final class PreviewVPN: Coder_Desktop.VPNService {
@Published var state: Coder_Desktop.VPNServiceState = .disabled
@Published var agents: [UUID: Coder_Desktop.Agent] = [
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
@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",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
wsName: "testing-a-very-long-name", wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
wsName: "testing-a-very-long-name", wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
wsID: UUID()),
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
wsID: UUID()),
]
], workspaces: [:])
let shouldFail: Bool
let longError = "This is a long error to test the UI with long error messages"

Expand Down
140 changes: 140 additions & 0 deletions Coder Desktop/Coder Desktop/VPNMenuState.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import Foundation
import SwiftUI
import VPNLib

struct Agent: Identifiable, Equatable, Comparable {
let id: UUID
let name: String
let status: AgentStatus
let hosts: [String]
let wsName: String
let wsID: UUID

// Agents are sorted by status, and then by name
static func < (lhs: Agent, rhs: Agent) -> Bool {
if lhs.status != rhs.status {
return lhs.status < rhs.status
}
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
}

// Hosts arrive sorted by length, the shortest looks best in the UI.
var primaryHost: String? { hosts.first }
}

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

public var color: Color {
switch self {
case .okay: .green
case .warn: .yellow
case .error: .red
case .off: .gray
}
}

static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

struct Workspace: Identifiable, Equatable, Comparable {
let id: UUID
let name: String
var agents: Set<UUID>

static func < (lhs: Workspace, rhs: Workspace) -> Bool {
lhs.name.localizedCompare(rhs.name) == .orderedAscending
}
}

struct VPNMenuState {
var agents: [UUID: Agent] = [:]
var workspaces: [UUID: Workspace] = [:]
// Upserted agents that don't belong to any known workspace, have no FQDNs,
// or have any invalid UUIDs.
var invalidAgents: [Vpn_Agent] = []

mutating func upsertAgent(_ agent: Vpn_Agent) {
guard
let id = UUID(uuidData: agent.id),
let wsID = UUID(uuidData: agent.workspaceID),
var workspace = workspaces[wsID],
!agent.fqdn.isEmpty
else {
invalidAgents.append(agent)
return
}
// An existing agent with the same name, belonging to the same workspace
// is from a previous workspace build, and should be removed.
agents.filter { $0.value.name == agent.name && $0.value.wsID == wsID }
.forEach { agents[$0.key] = nil }
workspace.agents.insert(id)
workspaces[wsID] = workspace

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,
// Remove trailing dot if present
hosts: agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 },
wsName: workspace.name,
wsID: wsID
)
}

mutating func deleteAgent(withId id: Data) {
guard let agentUUID = UUID(uuidData: id) else { return }
// Update Workspaces
if let agent = agents[agentUUID], var ws = workspaces[agent.wsID] {
ws.agents.remove(agentUUID)
workspaces[agent.wsID] = ws
}
agents[agentUUID] = nil
// Remove from invalid agents if present
invalidAgents.removeAll { invalidAgent in
invalidAgent.id == id
}
}

mutating func upsertWorkspace(_ workspace: Vpn_Workspace) {
guard let wsID = UUID(uuidData: workspace.id) else { return }
workspaces[wsID] = Workspace(id: wsID, name: workspace.name, agents: [])
// Check if we can associate any invalid agents with this workspace
invalidAgents.filter { agent in
agent.workspaceID == workspace.id
}.forEach { agent in
invalidAgents.removeAll { $0 == agent }
upsertAgent(agent)
}
}

mutating func deleteWorkspace(withId id: Data) {
guard let wsID = UUID(uuidData: id) else { return }
agents.filter { _, value in
value.wsID == wsID
}.forEach { key, _ in
agents[key] = nil
}
workspaces[wsID] = nil
}

var sorted: [VPNMenuItem] {
var items = agents.values.map { VPNMenuItem.agent($0) }
// Workspaces with no agents are shown as offline
items += workspaces.filter { _, value in
value.agents.isEmpty
}.map { VPNMenuItem.offlineWorkspace(Workspace(id: $0.key, name: $0.value.name, agents: $0.value.agents)) }
return items.sorted()
}

mutating func clear() {
agents.removeAll()
workspaces.removeAll()
}
}
64 changes: 8 additions & 56 deletions Coder Desktop/Coder Desktop/VPNService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import VPNLib
@MainActor
protocol VPNService: ObservableObject {
var state: VPNServiceState { get }
var agents: [UUID: Agent] { get }
var menuState: VPNMenuState { get }
func start() async
func stop() async
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
Expand Down Expand Up @@ -41,7 +41,6 @@ enum VPNServiceError: Error, Equatable {
final class CoderVPNService: NSObject, VPNService {
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
lazy var xpc: VPNXPCInterface = .init(vpn: self)
var workspaces: [UUID: String] = [:]

@Published var tunnelState: VPNServiceState = .disabled
@Published var sysExtnState: SystemExtensionState = .uninstalled
Expand All @@ -56,7 +55,7 @@ final class CoderVPNService: NSObject, VPNService {
return tunnelState
}

@Published var agents: [UUID: Agent] = [:]
@Published var menuState: VPNMenuState = .init()

// systemExtnDelegate holds a reference to the SystemExtensionDelegate so that it doesn't get
// garbage collected while the OSSystemExtensionRequest is in flight, since the OS framework
Expand Down Expand Up @@ -85,11 +84,6 @@ final class CoderVPNService: NSObject, VPNService {
NotificationCenter.default.removeObserver(self)
}

func clearPeers() {
agents = [:]
workspaces = [:]
}

func start() async {
switch tunnelState {
case .disabled, .failed:
Expand Down Expand Up @@ -150,7 +144,7 @@ final class CoderVPNService: NSObject, VPNService {
do {
let msg = try Vpn_PeerUpdate(serializedBytes: data)
debugPrint(msg)
clearPeers()
menuState.clear()
applyPeerUpdate(with: msg)
} catch {
logger.error("failed to decode peer update \(error)")
Expand All @@ -159,53 +153,11 @@ final class CoderVPNService: NSObject, VPNService {

func applyPeerUpdate(with update: Vpn_PeerUpdate) {
// Delete agents
update.deletedAgents
.compactMap { UUID(uuidData: $0.id) }
.forEach { agentID in
agents[agentID] = nil
}
update.deletedWorkspaces
.compactMap { UUID(uuidData: $0.id) }
.forEach { workspaceID in
workspaces[workspaceID] = nil
for (id, agent) in agents where agent.wsID == workspaceID {
agents[id] = nil
}
}

// Update workspaces
for workspaceProto in update.upsertedWorkspaces {
if let workspaceID = UUID(uuidData: workspaceProto.id) {
workspaces[workspaceID] = workspaceProto.name
}
}

for agentProto in update.upsertedAgents {
guard let agentID = UUID(uuidData: agentProto.id) else {
continue
}
guard let workspaceID = UUID(uuidData: agentProto.workspaceID) else {
continue
}
let workspaceName = workspaces[workspaceID] ?? "Unknown Workspace"
let newAgent = Agent(
id: agentID,
name: agentProto.name,
// If last handshake was not within last five minutes, the agent is unhealthy
status: agentProto.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .off,
copyableDNS: agentProto.fqdn.first ?? "UNKNOWN",
wsName: workspaceName,
wsID: workspaceID
)

// An existing agent with the same name, belonging to the same workspace
// is from a previous workspace build, and should be removed.
agents
.filter { $0.value.name == agentProto.name && $0.value.wsID == workspaceID }
.forEach { agents[$0.key] = nil }

agents[agentID] = newAgent
}
update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) }
update.deletedWorkspaces.forEach { menuState.deleteWorkspace(withId: $0.id) }
// Upsert workspaces before agents to populate agent workspace names
update.upsertedWorkspaces.forEach { menuState.upsertWorkspace($0) }
update.upsertedAgents.forEach { menuState.upsertAgent($0) }
}
}

Expand Down
Loading

0 comments on commit 64b8d52

Please sign in to comment.