Skip to content

Commit 64b8d52

Browse files
fix: display offline workspaces (#41)
Redo of #39.
1 parent 2bfe5bd commit 64b8d52

16 files changed

+611
-309
lines changed

Coder Desktop/Coder Desktop/About.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import SwiftUI
22

33
enum About {
4+
public static let repo: String = "https://github.com/coder/coder-desktop-macos"
45
private static var credits: NSAttributedString {
56
let coder = NSMutableAttributedString(
67
string: "Coder.com",
@@ -21,7 +22,7 @@ enum About {
2122
string: "GitHub",
2223
attributes: [
2324
.foregroundColor: NSColor.labelColor,
24-
.link: NSURL(string: "https://github.com/coder/coder-desktop-macos")!,
25+
.link: NSURL(string: About.repo)!,
2526
.font: NSFont.systemFont(ofSize: NSFont.systemFontSize),
2627
]
2728
)

Coder Desktop/Coder Desktop/Preview Content/PreviewVPN.swift

+13-13
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,29 @@ import SwiftUI
33

44
@MainActor
55
final class PreviewVPN: Coder_Desktop.VPNService {
6-
@Published var state: Coder_Desktop.VPNServiceState = .disabled
7-
@Published var agents: [UUID: Coder_Desktop.Agent] = [
8-
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
6+
@Published var state: Coder_Desktop.VPNServiceState = .connected
7+
@Published var menuState: VPNMenuState = .init(agents: [
8+
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
99
wsID: UUID()),
10-
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
10+
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
1111
wsName: "testing-a-very-long-name", wsID: UUID()),
12-
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
12+
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
1313
wsID: UUID()),
14-
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
14+
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
1515
wsID: UUID()),
16-
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
16+
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
1717
wsID: UUID()),
18-
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
18+
UUID(): Agent(id: UUID(), name: "dev", status: .error, hosts: ["asdf.coder"], wsName: "dogfood2",
1919
wsID: UUID()),
20-
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
20+
UUID(): Agent(id: UUID(), name: "dev", status: .okay, hosts: ["asdf.coder"],
2121
wsName: "testing-a-very-long-name", wsID: UUID()),
22-
UUID(): Agent(id: UUID(), name: "dev", status: .warn, copyableDNS: "asdf.coder", wsName: "opensrc",
22+
UUID(): Agent(id: UUID(), name: "dev", status: .warn, hosts: ["asdf.coder"], wsName: "opensrc",
2323
wsID: UUID()),
24-
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "gvisor",
24+
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "gvisor",
2525
wsID: UUID()),
26-
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "asdf.coder", wsName: "example",
26+
UUID(): Agent(id: UUID(), name: "dev", status: .off, hosts: ["asdf.coder"], wsName: "example",
2727
wsID: UUID()),
28-
]
28+
], workspaces: [:])
2929
let shouldFail: Bool
3030
let longError = "This is a long error to test the UI with long error messages"
3131

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import Foundation
2+
import SwiftUI
3+
import VPNLib
4+
5+
struct Agent: Identifiable, Equatable, Comparable {
6+
let id: UUID
7+
let name: String
8+
let status: AgentStatus
9+
let hosts: [String]
10+
let wsName: String
11+
let wsID: UUID
12+
13+
// Agents are sorted by status, and then by name
14+
static func < (lhs: Agent, rhs: Agent) -> Bool {
15+
if lhs.status != rhs.status {
16+
return lhs.status < rhs.status
17+
}
18+
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
19+
}
20+
21+
// Hosts arrive sorted by length, the shortest looks best in the UI.
22+
var primaryHost: String? { hosts.first }
23+
}
24+
25+
enum AgentStatus: Int, Equatable, Comparable {
26+
case okay = 0
27+
case warn = 1
28+
case error = 2
29+
case off = 3
30+
31+
public var color: Color {
32+
switch self {
33+
case .okay: .green
34+
case .warn: .yellow
35+
case .error: .red
36+
case .off: .gray
37+
}
38+
}
39+
40+
static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool {
41+
lhs.rawValue < rhs.rawValue
42+
}
43+
}
44+
45+
struct Workspace: Identifiable, Equatable, Comparable {
46+
let id: UUID
47+
let name: String
48+
var agents: Set<UUID>
49+
50+
static func < (lhs: Workspace, rhs: Workspace) -> Bool {
51+
lhs.name.localizedCompare(rhs.name) == .orderedAscending
52+
}
53+
}
54+
55+
struct VPNMenuState {
56+
var agents: [UUID: Agent] = [:]
57+
var workspaces: [UUID: Workspace] = [:]
58+
// Upserted agents that don't belong to any known workspace, have no FQDNs,
59+
// or have any invalid UUIDs.
60+
var invalidAgents: [Vpn_Agent] = []
61+
62+
mutating func upsertAgent(_ agent: Vpn_Agent) {
63+
guard
64+
let id = UUID(uuidData: agent.id),
65+
let wsID = UUID(uuidData: agent.workspaceID),
66+
var workspace = workspaces[wsID],
67+
!agent.fqdn.isEmpty
68+
else {
69+
invalidAgents.append(agent)
70+
return
71+
}
72+
// An existing agent with the same name, belonging to the same workspace
73+
// is from a previous workspace build, and should be removed.
74+
agents.filter { $0.value.name == agent.name && $0.value.wsID == wsID }
75+
.forEach { agents[$0.key] = nil }
76+
workspace.agents.insert(id)
77+
workspaces[wsID] = workspace
78+
79+
agents[id] = Agent(
80+
id: id,
81+
name: agent.name,
82+
// If last handshake was not within last five minutes, the agent is unhealthy
83+
status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn,
84+
// Remove trailing dot if present
85+
hosts: agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 },
86+
wsName: workspace.name,
87+
wsID: wsID
88+
)
89+
}
90+
91+
mutating func deleteAgent(withId id: Data) {
92+
guard let agentUUID = UUID(uuidData: id) else { return }
93+
// Update Workspaces
94+
if let agent = agents[agentUUID], var ws = workspaces[agent.wsID] {
95+
ws.agents.remove(agentUUID)
96+
workspaces[agent.wsID] = ws
97+
}
98+
agents[agentUUID] = nil
99+
// Remove from invalid agents if present
100+
invalidAgents.removeAll { invalidAgent in
101+
invalidAgent.id == id
102+
}
103+
}
104+
105+
mutating func upsertWorkspace(_ workspace: Vpn_Workspace) {
106+
guard let wsID = UUID(uuidData: workspace.id) else { return }
107+
workspaces[wsID] = Workspace(id: wsID, name: workspace.name, agents: [])
108+
// Check if we can associate any invalid agents with this workspace
109+
invalidAgents.filter { agent in
110+
agent.workspaceID == workspace.id
111+
}.forEach { agent in
112+
invalidAgents.removeAll { $0 == agent }
113+
upsertAgent(agent)
114+
}
115+
}
116+
117+
mutating func deleteWorkspace(withId id: Data) {
118+
guard let wsID = UUID(uuidData: id) else { return }
119+
agents.filter { _, value in
120+
value.wsID == wsID
121+
}.forEach { key, _ in
122+
agents[key] = nil
123+
}
124+
workspaces[wsID] = nil
125+
}
126+
127+
var sorted: [VPNMenuItem] {
128+
var items = agents.values.map { VPNMenuItem.agent($0) }
129+
// Workspaces with no agents are shown as offline
130+
items += workspaces.filter { _, value in
131+
value.agents.isEmpty
132+
}.map { VPNMenuItem.offlineWorkspace(Workspace(id: $0.key, name: $0.value.name, agents: $0.value.agents)) }
133+
return items.sorted()
134+
}
135+
136+
mutating func clear() {
137+
agents.removeAll()
138+
workspaces.removeAll()
139+
}
140+
}

Coder Desktop/Coder Desktop/VPNService.swift

+8-56
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import VPNLib
66
@MainActor
77
protocol VPNService: ObservableObject {
88
var state: VPNServiceState { get }
9-
var agents: [UUID: Agent] { get }
9+
var menuState: VPNMenuState { get }
1010
func start() async
1111
func stop() async
1212
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
@@ -41,7 +41,6 @@ enum VPNServiceError: Error, Equatable {
4141
final class CoderVPNService: NSObject, VPNService {
4242
var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "vpn")
4343
lazy var xpc: VPNXPCInterface = .init(vpn: self)
44-
var workspaces: [UUID: String] = [:]
4544

4645
@Published var tunnelState: VPNServiceState = .disabled
4746
@Published var sysExtnState: SystemExtensionState = .uninstalled
@@ -56,7 +55,7 @@ final class CoderVPNService: NSObject, VPNService {
5655
return tunnelState
5756
}
5857

59-
@Published var agents: [UUID: Agent] = [:]
58+
@Published var menuState: VPNMenuState = .init()
6059

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

88-
func clearPeers() {
89-
agents = [:]
90-
workspaces = [:]
91-
}
92-
9387
func start() async {
9488
switch tunnelState {
9589
case .disabled, .failed:
@@ -150,7 +144,7 @@ final class CoderVPNService: NSObject, VPNService {
150144
do {
151145
let msg = try Vpn_PeerUpdate(serializedBytes: data)
152146
debugPrint(msg)
153-
clearPeers()
147+
menuState.clear()
154148
applyPeerUpdate(with: msg)
155149
} catch {
156150
logger.error("failed to decode peer update \(error)")
@@ -159,53 +153,11 @@ final class CoderVPNService: NSObject, VPNService {
159153

160154
func applyPeerUpdate(with update: Vpn_PeerUpdate) {
161155
// Delete agents
162-
update.deletedAgents
163-
.compactMap { UUID(uuidData: $0.id) }
164-
.forEach { agentID in
165-
agents[agentID] = nil
166-
}
167-
update.deletedWorkspaces
168-
.compactMap { UUID(uuidData: $0.id) }
169-
.forEach { workspaceID in
170-
workspaces[workspaceID] = nil
171-
for (id, agent) in agents where agent.wsID == workspaceID {
172-
agents[id] = nil
173-
}
174-
}
175-
176-
// Update workspaces
177-
for workspaceProto in update.upsertedWorkspaces {
178-
if let workspaceID = UUID(uuidData: workspaceProto.id) {
179-
workspaces[workspaceID] = workspaceProto.name
180-
}
181-
}
182-
183-
for agentProto in update.upsertedAgents {
184-
guard let agentID = UUID(uuidData: agentProto.id) else {
185-
continue
186-
}
187-
guard let workspaceID = UUID(uuidData: agentProto.workspaceID) else {
188-
continue
189-
}
190-
let workspaceName = workspaces[workspaceID] ?? "Unknown Workspace"
191-
let newAgent = Agent(
192-
id: agentID,
193-
name: agentProto.name,
194-
// If last handshake was not within last five minutes, the agent is unhealthy
195-
status: agentProto.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .off,
196-
copyableDNS: agentProto.fqdn.first ?? "UNKNOWN",
197-
wsName: workspaceName,
198-
wsID: workspaceID
199-
)
200-
201-
// An existing agent with the same name, belonging to the same workspace
202-
// is from a previous workspace build, and should be removed.
203-
agents
204-
.filter { $0.value.name == agentProto.name && $0.value.wsID == workspaceID }
205-
.forEach { agents[$0.key] = nil }
206-
207-
agents[agentID] = newAgent
208-
}
156+
update.deletedAgents.forEach { menuState.deleteAgent(withId: $0.id) }
157+
update.deletedWorkspaces.forEach { menuState.deleteWorkspace(withId: $0.id) }
158+
// Upsert workspaces before agents to populate agent workspace names
159+
update.upsertedWorkspaces.forEach { menuState.upsertWorkspace($0) }
160+
update.upsertedAgents.forEach { menuState.upsertAgent($0) }
209161
}
210162
}
211163

0 commit comments

Comments
 (0)