Skip to content

Commit caf84f1

Browse files
committed
fix: display offline workspaces
1 parent 024d7e3 commit caf84f1

File tree

11 files changed

+404
-287
lines changed

11 files changed

+404
-287
lines changed

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import SwiftUI
44
@MainActor
55
final class PreviewVPN: Coder_Desktop.VPNService {
66
@Published var state: Coder_Desktop.VPNServiceState = .disabled
7-
@Published var agents: [UUID: Coder_Desktop.Agent] = [
7+
@Published var menuState: VPNMenuState = .init(agents: [
88
UUID(): Agent(id: UUID(), name: "dev", status: .error, copyableDNS: "asdf.coder", wsName: "dogfood2",
99
wsID: UUID()),
1010
UUID(): Agent(id: UUID(), name: "dev", status: .okay, copyableDNS: "asdf.coder",
@@ -25,7 +25,7 @@ final class PreviewVPN: Coder_Desktop.VPNService {
2525
wsID: UUID()),
2626
UUID(): Agent(id: UUID(), name: "dev", status: .off, copyableDNS: "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,116 @@
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 copyableDNS: 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+
22+
enum AgentStatus: Int, Equatable, Comparable {
23+
case okay = 0
24+
case warn = 1
25+
case error = 2
26+
case off = 3
27+
28+
public var color: Color {
29+
switch self {
30+
case .okay: .green
31+
case .warn: .yellow
32+
case .error: .red
33+
case .off: .gray
34+
}
35+
}
36+
37+
static func < (lhs: AgentStatus, rhs: AgentStatus) -> Bool {
38+
lhs.rawValue < rhs.rawValue
39+
}
40+
}
41+
42+
struct Workspace: Identifiable, Equatable, Comparable {
43+
let id: UUID
44+
let name: String
45+
var agents: [UUID]
46+
47+
static func < (lhs: Workspace, rhs: Workspace) -> Bool {
48+
lhs.name.localizedCompare(rhs.name) == .orderedAscending
49+
}
50+
}
51+
52+
struct VPNMenuState {
53+
var agents: [UUID: Agent] = [:]
54+
var workspaces: [UUID: Workspace] = [:]
55+
56+
mutating func upsertAgent(_ agent: Vpn_Agent) {
57+
guard let id = UUID(uuidData: agent.id) else { return }
58+
guard let wsID = UUID(uuidData: agent.workspaceID) else { return }
59+
// An existing agent with the same name, belonging to the same workspace
60+
// is from a previous workspace build, and should be removed.
61+
agents.filter { $0.value.name == agent.name && $0.value.wsID == wsID }
62+
.forEach { agents[$0.key] = nil }
63+
workspaces[wsID]?.agents.append(id)
64+
let wsName = workspaces[wsID]?.name ?? "Unknown Workspace"
65+
agents[id] = Agent(
66+
id: id,
67+
name: agent.name,
68+
// If last handshake was not within last five minutes, the agent is unhealthy
69+
status: agent.lastHandshake.date > Date.now.addingTimeInterval(-300) ? .okay : .warn,
70+
// Choose the shortest hostname, and remove trailing dot if present
71+
copyableDNS: agent.fqdn.min(by: { $0.count < $1.count })
72+
.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 } ?? "UNKNOWN",
73+
wsName: wsName,
74+
wsID: wsID
75+
)
76+
}
77+
78+
mutating func deleteAgent(withId id: Data) {
79+
guard let id = UUID(uuidData: id) else { return }
80+
// Update Workspaces
81+
if let agent = agents[id], var ws = workspaces[agent.wsID] {
82+
ws.agents.removeAll { $0 == id }
83+
workspaces[agent.wsID] = ws
84+
}
85+
agents[id] = nil
86+
}
87+
88+
mutating func upsertWorkspace(_ workspace: Vpn_Workspace) {
89+
guard let id = UUID(uuidData: workspace.id) else { return }
90+
workspaces[id] = Workspace(id: id, name: workspace.name, agents: [])
91+
}
92+
93+
mutating func deleteWorkspace(withId id: Data) {
94+
guard let wsID = UUID(uuidData: id) else { return }
95+
agents.filter { _, value in
96+
value.wsID == wsID
97+
}.forEach { key, _ in
98+
agents[key] = nil
99+
}
100+
workspaces[wsID] = nil
101+
}
102+
103+
func sorted() -> [VPNMenuItem] {
104+
var items = agents.values.map { VPNMenuItem.agent($0) }
105+
// Workspaces with no agents are shown as offline
106+
items += workspaces.filter { _, value in
107+
value.agents.isEmpty
108+
}.map { VPNMenuItem.offlineWorkspace(Workspace(id: $0.key, name: $0.value.name, agents: $0.value.agents)) }
109+
return items.sorted()
110+
}
111+
112+
mutating func clear() {
113+
agents.removeAll()
114+
workspaces.removeAll()
115+
}
116+
}

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

Coder Desktop/Coder Desktop/Views/Agent.swift

-99
This file was deleted.

Coder Desktop/Coder Desktop/Views/Agents.swift

+6-6
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,15 @@ struct Agents<VPN: VPNService, S: Session>: View {
1212
Group {
1313
// Agents List
1414
if vpn.state == .connected {
15-
let sortedAgents = vpn.agents.values.sorted()
16-
let visibleData = viewAll ? sortedAgents[...] : sortedAgents.prefix(defaultVisibleRows)
17-
ForEach(visibleData, id: \.id) { agent in
18-
AgentRowView(agent: agent, baseAccessURL: session.baseAccessURL!)
15+
let items = vpn.menuState.sorted()
16+
let visibleItems = viewAll ? items[...] : items.prefix(defaultVisibleRows)
17+
ForEach(visibleItems, id: \.id) { agent in
18+
MenuItemView(item: agent, baseAccessURL: session.baseAccessURL!)
1919
.padding(.horizontal, Theme.Size.trayMargin)
2020
}
21-
if vpn.agents.count > defaultVisibleRows {
21+
if items.count > defaultVisibleRows {
2222
Toggle(isOn: $viewAll) {
23-
Text(viewAll ? "Show Less" : "Show All")
23+
Text(viewAll ? "Show less" : "Show all")
2424
.font(.headline)
2525
.foregroundColor(.gray)
2626
.padding(.horizontal, Theme.Size.trayInset)

Coder Desktop/Coder Desktop/Views/VPNMenu.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ struct VPNMenu<VPN: VPNService, S: Session>: View {
2828
.disabled(vpnDisabled)
2929
}
3030
Divider()
31-
Text("Workspace Agents")
31+
Text("Workspaces")
3232
.font(.headline)
3333
.foregroundColor(.gray)
3434
VPNState<VPN, S>()

0 commit comments

Comments
 (0)