Skip to content
2 changes: 1 addition & 1 deletion Coder-Desktop/Coder-Desktop/Coder_DesktopApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
await self.state.handleTokenExpiry()
}
}, content: {
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 256)
VPNMenu<CoderVPNService, MutagenDaemon>().frame(width: 320)
.environmentObject(self.vpn)
.environmentObject(self.state)
.environmentObject(self.fileSyncDaemon)
Expand Down
4 changes: 4 additions & 0 deletions Coder-Desktop/Coder-Desktop/Preview Content/PreviewVPN.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,5 +81,9 @@ final class PreviewVPN: Coder_Desktop.VPNService {
state = .connecting
}

func setAgentParentID(agentID: UUID, parentID: UUID?) {
menuState.setAgentParentID(agentID: agentID, parentID: parentID)
}

var startWhenReady: Bool = false
}
62 changes: 53 additions & 9 deletions Coder-Desktop/Coder-Desktop/VPN/MenuState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
let hosts: [String]
let wsName: String
let wsID: UUID
// parentID is enriched from the HTTP API after the VPN proto delivers the
// agent. nil means top-level.
var parentID: UUID?
let lastPing: LastPing?
let lastHandshake: Date?

Expand All @@ -19,6 +22,7 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
hosts: [String],
wsName: String,
wsID: UUID,
parentID: UUID? = nil,
lastPing: LastPing? = nil,
lastHandshake: Date? = nil,
primaryHost: String)
Expand All @@ -29,17 +33,23 @@ struct Agent: Identifiable, Equatable, Comparable, Hashable {
self.hosts = hosts
self.wsName = wsName
self.wsID = wsID
self.parentID = parentID
self.lastPing = lastPing
self.lastHandshake = lastHandshake
self.primaryHost = primaryHost
}

// Agents are sorted by status, and then by name
// Agents are sorted by status, then by workspace name, then by agent name
// (the last tie-break matters for groups with multiple agents in the same
// workspace).
static func < (lhs: Agent, rhs: Agent) -> Bool {
if lhs.status != rhs.status {
return lhs.status < rhs.status
}
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
if lhs.wsName != rhs.wsName {
return lhs.wsName.localizedCompare(rhs.wsName) == .orderedAscending
}
return lhs.name.localizedCompare(rhs.name) == .orderedAscending
}

var statusString: String {
Expand Down Expand Up @@ -177,6 +187,11 @@ struct VPNMenuState {
// Remove trailing dot if present
let nonEmptyHosts = agent.fqdn.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 }

// The proto doesn't carry parent_id, so preserve any value we already
// enriched from the HTTP API. Captured before the same-name dedupe
// below clears the existing entry.
let existingParentID = agents[id]?.parentID

// 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 }
Expand All @@ -203,13 +218,20 @@ struct VPNMenuState {
hosts: nonEmptyHosts,
wsName: workspace.name,
wsID: wsID,
parentID: existingParentID,
lastPing: lastPing,
lastHandshake: agent.lastHandshake.maybeDate,
// Hosts arrive sorted by length, the shortest looks best in the UI.
primaryHost: nonEmptyHosts.first!
)
}

mutating func setAgentParentID(agentID: UUID, parentID: UUID?) {
guard var agent = agents[agentID], agent.parentID != parentID else { return }
agent.parentID = parentID
agents[agentID] = agent
}

mutating func deleteAgent(withId id: Data) {
guard let agentUUID = UUID(uuidData: id) else { return }
// Update Workspaces
Expand Down Expand Up @@ -248,13 +270,35 @@ struct VPNMenuState {
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()
// Groups all known agents under their workspace, nesting child agents
// (those with a parentID) under their parent. Empty workspaces still appear
// as offline groups.
var grouped: [WorkspaceGroup] {
let agentsByWorkspace = Dictionary(grouping: agents.values, by: \.wsID)

// Start from the known workspaces, then synthesize one for any wsID
// that shows up in agents without a workspace upsert (test fixtures
// and out-of-order proto delivery do this).
var workspacesByID = workspaces
for (wsID, wsAgents) in agentsByWorkspace where workspacesByID[wsID] == nil {
guard let firstAgent = wsAgents.first else { continue }
workspacesByID[wsID] = Workspace(
id: wsID,
name: firstAgent.wsName,
agents: Set(wsAgents.map(\.id))
)
}

return workspacesByID
.map { wsID, workspace in
// Normalize the workspace id to the dictionary key — matches
// the existing var sorted behavior, and tests rely on it.
WorkspaceGroup(
workspace: Workspace(id: wsID, name: workspace.name, agents: workspace.agents),
agents: agentsByWorkspace[wsID] ?? []
)
}
.sorted()
}

var onlineAgents: [Agent] { agents.map(\.value) }
Expand Down
9 changes: 9 additions & 0 deletions Coder-Desktop/Coder-Desktop/VPN/VPNService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ protocol VPNService: ObservableObject {
func stop() async
func configureTunnelProviderProtocol(proto: NETunnelProviderProtocol?)
var startWhenReady: Bool { get set }

// Backfill parent_id for an agent. Sourced from the HTTP API since the VPN
// proto doesn't carry it. Called by the UI layer after fetching workspace
// details so child agents can be nested under their parent.
func setAgentParentID(agentID: UUID, parentID: UUID?)
}

enum VPNServiceState: Equatable {
Expand Down Expand Up @@ -177,6 +182,10 @@ final class CoderVPNService: NSObject, VPNService {
update.upsertedWorkspaces.forEach { menuState.upsertWorkspace($0) }
update.upsertedAgents.forEach { menuState.upsertAgent($0) }
}

func setAgentParentID(agentID: UUID, parentID: UUID?) {
menuState.setAgentParentID(agentID: agentID, parentID: parentID)
}
}

extension CoderVPNService {
Expand Down
61 changes: 61 additions & 0 deletions Coder-Desktop/Coder-Desktop/VPN/WorkspaceGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import Foundation

/// WorkspaceGroup is the unit the tray renders: one workspace with the agents
/// belonging to it, organized into a parent/child tree.
struct WorkspaceGroup: Identifiable, Equatable, Comparable {
let workspace: Workspace
/// All agents in the workspace, unsorted.
let agents: [Agent]

var id: UUID {
workspace.id
}

/// Aggregate status: worst-of among agents, or .off if the workspace has
/// none (treated as offline).
var status: AgentStatus {
agents.map(\.status).max() ?? .off
}

/// Top-level agents are those without a parent, plus any agent whose
/// parentID points outside this group's known agents (orphans render at
/// the top level rather than disappearing).
var topLevelAgents: [Agent] {
let knownIDs = Set(agents.map(\.id))
return agents
.filter { agent in
guard let parentID = agent.parentID else { return true }
return !knownIDs.contains(parentID)
}
.sorted()
}

/// Children of the given parent agent, sorted.
func children(of parentID: UUID) -> [Agent] {
agents.filter { $0.parentID == parentID }.sorted()
}

/// Tree-walked agents paired with their indent depth (1 for top-level).
/// Handles arbitrary parent_id depth; cycles are guarded against by
/// tracking visited IDs.
var indentedAgents: [(agent: Agent, indent: Int)] {
var result: [(Agent, Int)] = []
var visited: Set<UUID> = []
func walk(_ agent: Agent, indent: Int) {
guard visited.insert(agent.id).inserted else { return }
result.append((agent, indent))
for child in children(of: agent.id) {
walk(child, indent: indent + 1)
}
}
for top in topLevelAgents {
walk(top, indent: 1)
}
return result
}

static func < (lhs: WorkspaceGroup, rhs: WorkspaceGroup) -> Bool {
if lhs.status != rhs.status { return lhs.status < rhs.status }
return lhs.workspace < rhs.workspace
}
}
145 changes: 145 additions & 0 deletions Coder-Desktop/Coder-Desktop/Views/VPN/AgentDetailRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import CoderSDK
import SwiftUI

/// Renders a single agent inside an expanded WorkspaceGroupView: status dot,
/// agent name + latency, optional ports menu / parent-apps toggle / copy
/// button, and the agent's apps inline below.
///
/// `leadingIcon` is rendered ahead of the status dot and is used to mark
/// sub-agent rows with a cube glyph so they're recognizable as containers
/// without a dedicated header. `appsToggle`, when set, exposes the show/hide
/// control for parent apps (mirrors the dashboard's "Show parent apps"
/// affordance).
struct AgentDetailRow: View {
@EnvironmentObject var state: AppState
@Environment(\.openURL) private var openURL

let agent: Agent
let apps: [WorkspaceApp]
var ports: [WorkspaceAgentListeningPort] = []
var appsToggle: AppsToggle?
var leadingIcon: String?
var leadingIconHelp: String?

@State private var nameIsSelected: Bool = false
/// Bumped on each copy so SwiftUI can drive the bounce symbol effect.
@State private var copyTick: Int = 0

struct AppsToggle {
let isShown: Bool
let action: () -> Void
}

var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 3) {
HStack(spacing: Theme.Size.trayPadding) {
if let leadingIcon {
Image(systemName: leadingIcon)
.symbolRenderingMode(.hierarchical)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.help(leadingIconHelp ?? "")
}
StatusDot(color: agent.status.color)
.help(agent.statusString)
Text(agent.name).lineLimit(1).truncationMode(.tail)
if let latency = agent.lastPing?.latency {
Text(formatLatency(latency))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, Theme.Size.trayPadding)
.frame(minHeight: 22)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(nameIsSelected ? .white : .primary)
.background(nameIsSelected ? Color.accentColor.opacity(0.8) : .clear)
.clipShape(.rect(cornerRadius: Theme.Size.rectCornerRadius))
.onHover { hovering in nameIsSelected = hovering }
.padding(.trailing, 3)
.draggable(agent.primaryHost)
if !ports.isEmpty {
portsMenu
}
if let appsToggle {
Button(action: appsToggle.action) {
Image(systemName: appsToggle.isShown ? "square.grid.2x2.fill" : "square.grid.2x2")
.symbolRenderingMode(.hierarchical)
.contentTransition(.symbolEffect(.replace))
.font(.system(size: 11))
.padding(3)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.controlSize(.small)
.help(appsToggle.isShown ? "Hide apps" : "Show apps")
}
Button {
copyToClipboard()
} label: {
Image(systemName: "doc.on.doc.fill")
.symbolRenderingMode(.hierarchical)
.symbolEffect(.bounce, value: copyTick)
.font(.system(size: 9))
.padding(3)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.controlSize(.small)
.padding(.trailing, Theme.Size.trayMargin)
.help("Copy hostname")
}
if !apps.isEmpty {
MenuItemCollapsibleView(apps: apps)
}
}
}

private var portsMenu: some View {
Menu {
ForEach(ports) { port in
Button(label(for: port)) {
if let url = URL(string: "http://\(agent.primaryHost):\(port.port)") {
openURL(url)
}
}
}
} label: {
Label("Listening ports", systemImage: "dot.radiowaves.right")
.labelStyle(.iconOnly)
.symbolRenderingMode(.hierarchical)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.padding(3)
}
.badge(ports.count)
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.controlSize(.small)
.fixedSize()
.help("Listening ports")
}

private func copyToClipboard() {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(agent.primaryHost, forType: .string)
copyTick &+= 1
}

/// Compact "18ms" formatting for inline display. The dot's tooltip still
/// carries the full status string with P2P/DERP detail.
private func formatLatency(_ seconds: TimeInterval) -> String {
let ms = Int((seconds * 1000).rounded())
return "\(ms)ms"
}

private func label(for port: WorkspaceAgentListeningPort) -> String {
let processName = port.process_name.isEmpty ? nil : port.process_name
if let processName {
return "\(port.port) - \(processName)"
}
return "\(port.port)"
}
}
Loading
Loading