Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit ce1883e

Browse files
committedFeb 11, 2025·
fix: display offline workspaces
1 parent e64ea22 commit ce1883e

File tree

11 files changed

+404
-287
lines changed

11 files changed

+404
-287
lines changed
 

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

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

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

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

‎Coder Desktop/Coder Desktop/Views/Agent.swift

Lines changed: 0 additions & 99 deletions
This file was deleted.

‎Coder Desktop/Coder Desktop/Views/Agents.swift

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

Lines changed: 1 addition & 1 deletion
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>()
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import SwiftUI
2+
3+
// Each row in the workspaces list is an agent or an offline workspace
4+
enum VPNMenuItem: Equatable, Comparable, Identifiable {
5+
case agent(Agent)
6+
case offlineWorkspace(Workspace)
7+
8+
var wsName: String {
9+
switch self {
10+
case let .agent(agent): agent.wsName
11+
case let .offlineWorkspace(workspace): workspace.name
12+
}
13+
}
14+
15+
var status: AgentStatus {
16+
switch self {
17+
case let .agent(agent): agent.status
18+
case .offlineWorkspace: .off
19+
}
20+
}
21+
22+
var id: UUID {
23+
switch self {
24+
case let .agent(agent): agent.id
25+
case let .offlineWorkspace(workspace): workspace.id
26+
}
27+
}
28+
29+
static func < (lhs: VPNMenuItem, rhs: VPNMenuItem) -> Bool {
30+
switch (lhs, rhs) {
31+
case let (.agent(lhsAgent), .agent(rhsAgent)):
32+
lhsAgent < rhsAgent
33+
case let (.offlineWorkspace(lhsWorkspace), .offlineWorkspace(rhsWorkspace)):
34+
lhsWorkspace < rhsWorkspace
35+
// Agents always appear before offline workspaces
36+
case (.offlineWorkspace, .agent):
37+
false
38+
case (.agent, .offlineWorkspace):
39+
true
40+
}
41+
}
42+
}
43+
44+
struct MenuItemView: View {
45+
let item: VPNMenuItem
46+
let baseAccessURL: URL
47+
@State private var nameIsSelected: Bool = false
48+
@State private var copyIsSelected: Bool = false
49+
50+
private var fmtWsName: AttributedString {
51+
var formattedName = AttributedString(item.wsName)
52+
formattedName.foregroundColor = .primary
53+
var coderPart = AttributedString(".coder")
54+
coderPart.foregroundColor = .gray
55+
formattedName.append(coderPart)
56+
return formattedName
57+
}
58+
59+
private var wsURL: URL {
60+
// TODO: CoderVPN currently only supports owned workspaces
61+
baseAccessURL.appending(path: "@me").appending(path: item.wsName)
62+
}
63+
64+
var body: some View {
65+
HStack(spacing: 0) {
66+
Link(destination: wsURL) {
67+
HStack(spacing: Theme.Size.trayPadding) {
68+
ZStack {
69+
Circle()
70+
.fill(item.status.color.opacity(0.4))
71+
.frame(width: 12, height: 12)
72+
Circle()
73+
.fill(item.status.color.opacity(1.0))
74+
.frame(width: 7, height: 7)
75+
}
76+
Text(fmtWsName).lineLimit(1).truncationMode(.tail)
77+
Spacer()
78+
}.padding(.horizontal, Theme.Size.trayPadding)
79+
.frame(minHeight: 22)
80+
.frame(maxWidth: .infinity, alignment: .leading)
81+
.foregroundStyle(nameIsSelected ? Color.white : .primary)
82+
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
83+
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
84+
.onHover { hovering in nameIsSelected = hovering }
85+
Spacer()
86+
}.buttonStyle(.plain)
87+
if case let .agent(agent) = item {
88+
Button {
89+
NSPasteboard.general.clearContents()
90+
NSPasteboard.general.setString(agent.copyableDNS, forType: .string)
91+
} label: {
92+
Image(systemName: "doc.on.doc")
93+
.symbolVariant(.fill)
94+
.padding(3)
95+
}.foregroundStyle(copyIsSelected ? Color.white : .primary)
96+
.imageScale(.small)
97+
.background(copyIsSelected ? Color.accentColor.opacity(0.8) : .clear)
98+
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
99+
.onHover { hovering in copyIsSelected = hovering }
100+
.buttonStyle(.plain)
101+
.padding(.trailing, Theme.Size.trayMargin)
102+
}
103+
}
104+
}
105+
}

‎Coder Desktop/Coder DesktopTests/AgentsTests.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ struct AgentsTests {
4444
@Test
4545
func agentsWhenVPNOn() throws {
4646
vpn.state = .connected
47-
vpn.agents = createMockAgents(count: Theme.defaultVisibleAgents + 2)
47+
vpn.menuState = .init(agents: createMockAgents(count: Theme.defaultVisibleAgents + 2))
4848

4949
let forEach = try view.inspect().find(ViewType.ForEach.self)
5050
#expect(forEach.count == Theme.defaultVisibleAgents)
@@ -55,24 +55,24 @@ struct AgentsTests {
5555
@Test
5656
func showAllToggle() async throws {
5757
vpn.state = .connected
58-
vpn.agents = createMockAgents(count: 7)
58+
vpn.menuState = .init(agents: createMockAgents(count: 7))
5959

6060
try await ViewHosting.host(view) {
6161
try await sut.inspection.inspect { view in
6262
var toggle = try view.find(ViewType.Toggle.self)
63-
#expect(try toggle.labelView().text().string() == "Show All")
63+
#expect(try toggle.labelView().text().string() == "Show all")
6464
#expect(try !toggle.isOn())
6565

6666
try toggle.tap()
6767
toggle = try view.find(ViewType.Toggle.self)
6868
var forEach = try view.find(ViewType.ForEach.self)
6969
#expect(forEach.count == Theme.defaultVisibleAgents + 2)
70-
#expect(try toggle.labelView().text().string() == "Show Less")
70+
#expect(try toggle.labelView().text().string() == "Show less")
7171

7272
try toggle.tap()
7373
toggle = try view.find(ViewType.Toggle.self)
7474
forEach = try view.find(ViewType.ForEach.self)
75-
#expect(try toggle.labelView().text().string() == "Show All")
75+
#expect(try toggle.labelView().text().string() == "Show all")
7676
#expect(forEach.count == Theme.defaultVisibleAgents)
7777
}
7878
}
@@ -81,7 +81,7 @@ struct AgentsTests {
8181
@Test
8282
func noToggleFewAgents() throws {
8383
vpn.state = .connected
84-
vpn.agents = createMockAgents(count: 3)
84+
vpn.menuState = .init(agents: createMockAgents(count: 3))
8585

8686
#expect(throws: (any Error).self) {
8787
_ = try view.inspect().find(ViewType.Toggle.self)

‎Coder Desktop/Coder DesktopTests/Util.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import ViewInspector
88
class MockVPNService: VPNService, ObservableObject {
99
@Published var state: Coder_Desktop.VPNServiceState = .disabled
1010
@Published var baseAccessURL: URL = .init(string: "https://dev.coder.com")!
11-
@Published var agents: [UUID: Coder_Desktop.Agent] = [:]
11+
@Published var menuState: VPNMenuState = .init()
1212
var onStart: (() async -> Void)?
1313
var onStop: (() async -> Void)?
1414

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
@testable import Coder_Desktop
2+
import Testing
3+
@testable import VPNLib
4+
5+
@MainActor
6+
@Suite
7+
struct VPNMenuStateTests {
8+
var state = VPNMenuState()
9+
10+
@Test
11+
mutating func testUpsertAgent_addsAgent() async throws {
12+
let agentID = UUID()
13+
let workspaceID = UUID()
14+
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
15+
16+
let agent = Vpn_Agent.with {
17+
$0.id = agentID.uuidData
18+
$0.workspaceID = workspaceID.uuidData
19+
$0.name = "dev"
20+
$0.lastHandshake = .init(date: Date.now)
21+
$0.fqdn = ["foo.coder"]
22+
}
23+
24+
state.upsertAgent(agent)
25+
26+
let storedAgent = try #require(state.agents[agentID])
27+
#expect(storedAgent.name == "dev")
28+
#expect(storedAgent.wsID == workspaceID)
29+
#expect(storedAgent.wsName == "foo")
30+
#expect(storedAgent.copyableDNS == "foo.coder")
31+
#expect(storedAgent.status == .okay)
32+
}
33+
34+
@Test
35+
mutating func testDeleteAgent_removesAgent() async throws {
36+
let agentID = UUID()
37+
let workspaceID = UUID()
38+
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
39+
40+
let agent = Vpn_Agent.with {
41+
$0.id = agentID.uuidData
42+
$0.workspaceID = workspaceID.uuidData
43+
$0.name = "agent1"
44+
$0.lastHandshake = .init(date: Date.now)
45+
$0.fqdn = ["foo.coder"]
46+
}
47+
48+
state.upsertAgent(agent)
49+
state.deleteAgent(withId: agent.id)
50+
51+
#expect(state.agents[agentID] == nil)
52+
}
53+
54+
@Test
55+
mutating func testDeleteWorkspace_removesWorkspaceAndAgents() async throws {
56+
let agentID = UUID()
57+
let workspaceID = UUID()
58+
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
59+
60+
let agent = Vpn_Agent.with {
61+
$0.id = agentID.uuidData
62+
$0.workspaceID = workspaceID.uuidData
63+
$0.name = "agent1"
64+
$0.lastHandshake = .init(date: Date.now)
65+
$0.fqdn = ["foo.coder"]
66+
}
67+
68+
state.upsertAgent(agent)
69+
state.deleteWorkspace(withId: workspaceID.uuidData)
70+
71+
#expect(state.agents[agentID] == nil)
72+
#expect(state.workspaces[workspaceID] == nil)
73+
}
74+
75+
@Test
76+
mutating func testUpsertAgent_unhealthyAgent() async throws {
77+
let agentID = UUID()
78+
let workspaceID = UUID()
79+
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
80+
81+
let agent = Vpn_Agent.with {
82+
$0.id = agentID.uuidData
83+
$0.workspaceID = workspaceID.uuidData
84+
$0.name = "agent1"
85+
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600))
86+
$0.fqdn = ["foo.coder"]
87+
}
88+
89+
state.upsertAgent(agent)
90+
91+
let storedAgent = try #require(state.agents[agentID])
92+
#expect(storedAgent.status == .warn)
93+
}
94+
95+
@Test
96+
mutating func testUpsertAgent_replacesOldAgent() async throws {
97+
let workspaceID = UUID()
98+
let oldAgentID = UUID()
99+
let newAgentID = UUID()
100+
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
101+
102+
let oldAgent = Vpn_Agent.with {
103+
$0.id = oldAgentID.uuidData
104+
$0.workspaceID = workspaceID.uuidData
105+
$0.name = "agent1"
106+
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-600))
107+
$0.fqdn = ["foo.coder"]
108+
}
109+
110+
state.upsertAgent(oldAgent)
111+
112+
let newAgent = Vpn_Agent.with {
113+
$0.id = newAgentID.uuidData
114+
$0.workspaceID = workspaceID.uuidData
115+
$0.name = "agent1" // Same name as old agent
116+
$0.lastHandshake = .init(date: Date.now)
117+
$0.fqdn = ["foo.coder"]
118+
}
119+
120+
state.upsertAgent(newAgent)
121+
122+
#expect(state.agents[oldAgentID] == nil)
123+
let storedAgent = try #require(state.agents[newAgentID])
124+
#expect(storedAgent.name == "agent1")
125+
#expect(storedAgent.wsID == workspaceID)
126+
#expect(storedAgent.copyableDNS == "foo.coder")
127+
#expect(storedAgent.status == .okay)
128+
}
129+
130+
@Test
131+
mutating func testUpsertWorkspace_addsOfflineWorkspace() async throws {
132+
let workspaceID = UUID()
133+
state.upsertWorkspace(Vpn_Workspace.with { $0.id = workspaceID.uuidData; $0.name = "foo" })
134+
135+
let storedWorkspace = try #require(state.workspaces[workspaceID])
136+
#expect(storedWorkspace.name == "foo")
137+
138+
var output = state.sorted()
139+
#expect(output.count == 1)
140+
#expect(output[0].id == workspaceID)
141+
#expect(output[0].wsName == "foo")
142+
143+
let agentID = UUID()
144+
let agent = Vpn_Agent.with {
145+
$0.id = agentID.uuidData
146+
$0.workspaceID = workspaceID.uuidData
147+
$0.name = "agent1"
148+
$0.lastHandshake = .init(date: Date.now.addingTimeInterval(-200))
149+
$0.fqdn = ["foo.coder"]
150+
}
151+
state.upsertAgent(agent)
152+
153+
output = state.sorted()
154+
#expect(output.count == 1)
155+
#expect(output[0].id == agentID)
156+
#expect(output[0].wsName == "foo")
157+
#expect(output[0].status == .okay)
158+
}
159+
}

‎Coder Desktop/Coder DesktopTests/VPNServiceTests.swift

Lines changed: 0 additions & 116 deletions
This file was deleted.

0 commit comments

Comments
 (0)
Please sign in to comment.