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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 13 additions & 13 deletions
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

Lines changed: 140 additions & 0 deletions
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

Lines changed: 8 additions & 56 deletions
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)
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