Skip to content

Commit b8d7993

Browse files
authored
1 parent 29943c8 commit b8d7993

File tree

4 files changed

+187
-35
lines changed

4 files changed

+187
-35
lines changed

App/ViewModels/AgentViewModel.cs

Lines changed: 124 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,3 @@
1-
using System;
2-
using System.Collections.Generic;
3-
using System.Collections.ObjectModel;
4-
using System.ComponentModel;
5-
using System.Linq;
6-
using System.Threading;
7-
using System.Threading.Tasks;
8-
using Windows.ApplicationModel.DataTransfer;
91
using Coder.Desktop.App.Services;
102
using Coder.Desktop.App.Utils;
113
using Coder.Desktop.CoderSdk;
@@ -18,15 +10,24 @@
1810
using Microsoft.UI.Xaml;
1911
using Microsoft.UI.Xaml.Controls;
2012
using Microsoft.UI.Xaml.Controls.Primitives;
13+
using System;
14+
using System.Collections.Generic;
15+
using System.Collections.ObjectModel;
16+
using System.ComponentModel;
17+
using System.Linq;
18+
using System.Text;
19+
using System.Threading;
20+
using System.Threading.Tasks;
21+
using System.Xml.Linq;
22+
using Windows.ApplicationModel.DataTransfer;
2123

2224
namespace Coder.Desktop.App.ViewModels;
2325

2426
public interface IAgentViewModelFactory
2527
{
2628
public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName,
27-
string hostnameSuffix,
28-
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName);
29-
29+
string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl,
30+
string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, DateTime? lastHandshake);
3031
public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id,
3132
string hostnameSuffix,
3233
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName);
@@ -40,7 +41,9 @@ public class AgentViewModelFactory(
4041
{
4142
public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName,
4243
string hostnameSuffix,
43-
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName)
44+
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl,
45+
string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency,
46+
DateTime? lastHandshake)
4447
{
4548
return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory,
4649
expanderHost, id)
@@ -51,6 +54,11 @@ public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fu
5154
ConnectionStatus = connectionStatus,
5255
DashboardBaseUrl = dashboardBaseUrl,
5356
WorkspaceName = workspaceName,
57+
DidP2p = didP2p,
58+
PreferredDerp = preferredDerp,
59+
Latency = latency,
60+
PreferredDerpLatency = preferredDerpLatency,
61+
LastHandshake = lastHandshake,
5462
};
5563
}
5664

@@ -73,10 +81,25 @@ public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id,
7381

7482
public enum AgentConnectionStatus
7583
{
76-
Green,
77-
Yellow,
78-
Red,
79-
Gray,
84+
Healthy,
85+
Connecting,
86+
Unhealthy,
87+
NoRecentHandshake,
88+
Offline
89+
}
90+
91+
public static class AgentConnectionStatusExtensions
92+
{
93+
public static string ToDisplayString(this AgentConnectionStatus status) =>
94+
status switch
95+
{
96+
AgentConnectionStatus.Healthy => "Healthy",
97+
AgentConnectionStatus.Connecting => "Connecting",
98+
AgentConnectionStatus.Unhealthy => "High latency",
99+
AgentConnectionStatus.NoRecentHandshake => "No recent handshake",
100+
AgentConnectionStatus.Offline => "Offline",
101+
_ => status.ToString()
102+
};
80103
}
81104

82105
public partial class AgentViewModel : ObservableObject, IModelUpdateable<AgentViewModel>
@@ -160,6 +183,7 @@ public string FullyQualifiedDomainName
160183
[ObservableProperty]
161184
[NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))]
162185
[NotifyPropertyChangedFor(nameof(ExpandAppsMessage))]
186+
[NotifyPropertyChangedFor(nameof(ConnectionTooltip))]
163187
public required partial AgentConnectionStatus ConnectionStatus { get; set; }
164188

165189
[ObservableProperty]
@@ -182,6 +206,77 @@ public string FullyQualifiedDomainName
182206
[NotifyPropertyChangedFor(nameof(ExpandAppsMessage))]
183207
public partial bool AppFetchErrored { get; set; } = false;
184208

209+
[ObservableProperty]
210+
[NotifyPropertyChangedFor(nameof(ConnectionTooltip))]
211+
public partial bool? DidP2p { get; set; } = false;
212+
213+
[ObservableProperty]
214+
[NotifyPropertyChangedFor(nameof(ConnectionTooltip))]
215+
public partial string? PreferredDerp { get; set; } = null;
216+
217+
[ObservableProperty]
218+
[NotifyPropertyChangedFor(nameof(ConnectionTooltip))]
219+
public partial TimeSpan? Latency { get; set; } = null;
220+
221+
[ObservableProperty]
222+
[NotifyPropertyChangedFor(nameof(ConnectionTooltip))]
223+
public partial TimeSpan? PreferredDerpLatency { get; set; } = null;
224+
225+
[ObservableProperty]
226+
[NotifyPropertyChangedFor(nameof(ConnectionTooltip))]
227+
public partial DateTime? LastHandshake { get; set; } = null;
228+
229+
public string ConnectionTooltip
230+
{
231+
get
232+
{
233+
var description = new StringBuilder();
234+
var highLatencyWarning = ConnectionStatus == AgentConnectionStatus.Unhealthy ? $"({AgentConnectionStatus.Unhealthy.ToDisplayString()})" : "";
235+
236+
if (DidP2p != null && DidP2p.Value && Latency != null)
237+
{
238+
description.Append($"""
239+
You're connected peer-to-peer. {highLatencyWarning}
240+
241+
You ↔ {Latency.Value.Milliseconds} ms ↔ {WorkspaceName}
242+
"""
243+
);
244+
}
245+
else if (Latency != null)
246+
{
247+
description.Append($"""
248+
You're connected through a DERP relay. {highLatencyWarning}
249+
We'll switch over to peer-to-peer when available.
250+
251+
Total latency: {Latency.Value.Milliseconds} ms
252+
"""
253+
);
254+
255+
if (PreferredDerpLatency != null)
256+
{
257+
description.Append($"\nYou ↔ {PreferredDerp}: {PreferredDerpLatency.Value.Milliseconds} ms");
258+
259+
var derpToWorkspaceEstimatedLatency = Latency - PreferredDerpLatency;
260+
261+
// Guard against negative values if the two readings were taken at different times
262+
if (derpToWorkspaceEstimatedLatency > TimeSpan.Zero)
263+
{
264+
description.Append($"\n{PreferredDerp} ms ↔ {WorkspaceName}: {derpToWorkspaceEstimatedLatency.Value.Milliseconds} ms");
265+
}
266+
}
267+
}
268+
else
269+
{
270+
description.Append(ConnectionStatus.ToDisplayString());
271+
}
272+
if (LastHandshake != null)
273+
description.Append($"\n\nLast handshake: {LastHandshake?.ToString()}");
274+
275+
return description.ToString().TrimEnd('\n', ' '); ;
276+
}
277+
}
278+
279+
185280
// We only show 6 apps max, which fills the entire width of the tray
186281
// window.
187282
public IEnumerable<AgentAppViewModel> VisibleApps => Apps.Count > MaxAppsPerRow ? Apps.Take(MaxAppsPerRow) : Apps;
@@ -192,7 +287,7 @@ public string? ExpandAppsMessage
192287
{
193288
get
194289
{
195-
if (ConnectionStatus == AgentConnectionStatus.Gray)
290+
if (ConnectionStatus == AgentConnectionStatus.Offline)
196291
return "Your workspace is offline.";
197292
if (FetchingApps && Apps.Count == 0)
198293
// Don't show this message if we have any apps already. When
@@ -285,6 +380,16 @@ public bool TryApplyChanges(AgentViewModel model)
285380
DashboardBaseUrl = model.DashboardBaseUrl;
286381
if (WorkspaceName != model.WorkspaceName)
287382
WorkspaceName = model.WorkspaceName;
383+
if (DidP2p != model.DidP2p)
384+
DidP2p = model.DidP2p;
385+
if (PreferredDerp != model.PreferredDerp)
386+
PreferredDerp = model.PreferredDerp;
387+
if (Latency != model.Latency)
388+
Latency = model.Latency;
389+
if (PreferredDerpLatency != model.PreferredDerpLatency)
390+
PreferredDerpLatency = model.PreferredDerpLatency;
391+
if (LastHandshake != model.LastHandshake)
392+
LastHandshake = model.LastHandshake;
288393

289394
// Apps are not set externally.
290395

@@ -307,7 +412,7 @@ public void SetExpanded(bool expanded)
307412

308413
partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue)
309414
{
310-
if (IsExpanded && newValue is not AgentConnectionStatus.Gray) FetchApps();
415+
if (IsExpanded && newValue is not AgentConnectionStatus.Offline) FetchApps();
311416
}
312417

313418
private void FetchApps()
@@ -316,7 +421,7 @@ private void FetchApps()
316421
FetchingApps = true;
317422

318423
// If the workspace is off, then there's no agent and there's no apps.
319-
if (ConnectionStatus == AgentConnectionStatus.Gray)
424+
if (ConnectionStatus == AgentConnectionStatus.Offline)
320425
{
321426
FetchingApps = false;
322427
Apps.Clear();

App/ViewModels/TrayWindowViewModel.cs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Collections.ObjectModel;
44
using System.ComponentModel;
55
using System.Linq;
6+
using System.Security.Principal;
67
using System.Threading.Tasks;
78
using Coder.Desktop.App.Models;
89
using Coder.Desktop.App.Services;
@@ -29,6 +30,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
2930
{
3031
private const int MaxAgents = 5;
3132
private const string DefaultDashboardUrl = "https://coder.com";
33+
private readonly TimeSpan HealthyPingThreshold = TimeSpan.FromMilliseconds(150);
3234

3335
private readonly IServiceProvider _services;
3436
private readonly IRpcController _rpcController;
@@ -222,10 +224,28 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
222224
if (string.IsNullOrWhiteSpace(fqdn))
223225
continue;
224226

225-
var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime());
226-
var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5)
227-
? AgentConnectionStatus.Green
228-
: AgentConnectionStatus.Yellow;
227+
var connectionStatus = AgentConnectionStatus.Healthy;
228+
229+
if (agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default && agent.LastHandshake.ToDateTime() < DateTime.UtcNow)
230+
{
231+
// For compatibility with older deployments, we assume that if the
232+
// last ping is null, the agent is healthy.
233+
var isLatencyAcceptable = agent.LastPing == null || agent.LastPing.Latency.ToTimeSpan() < HealthyPingThreshold;
234+
235+
var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime());
236+
237+
if (lastHandshakeAgo > TimeSpan.FromMinutes(5))
238+
connectionStatus = AgentConnectionStatus.NoRecentHandshake;
239+
else if (!isLatencyAcceptable)
240+
connectionStatus = AgentConnectionStatus.Unhealthy;
241+
}
242+
else
243+
{
244+
// If the last handshake is not correct (null, default or in the future),
245+
// we assume the agent is connecting (yellow status icon).
246+
connectionStatus = AgentConnectionStatus.Connecting;
247+
}
248+
229249
workspacesWithAgents.Add(agent.WorkspaceId);
230250
var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId);
231251

@@ -236,7 +256,12 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
236256
_hostnameSuffixGetter.GetCachedSuffix(),
237257
connectionStatus,
238258
credentialModel.CoderUrl,
239-
workspace?.Name));
259+
workspace?.Name,
260+
agent.LastPing?.DidP2P,
261+
agent.LastPing?.PreferredDerp,
262+
agent.LastPing?.Latency?.ToTimeSpan(),
263+
agent.LastPing?.PreferredDerpLatency?.ToTimeSpan(),
264+
agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default ? agent.LastHandshake?.ToDateTime() : null));
240265
}
241266

242267
// For every stopped workspace that doesn't have any agents, add a
@@ -253,7 +278,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
253278
// conflict with any agent IDs.
254279
uuid,
255280
_hostnameSuffixGetter.GetCachedSuffix(),
256-
AgentConnectionStatus.Gray,
281+
AgentConnectionStatus.Offline,
257282
credentialModel.CoderUrl,
258283
workspace.Name));
259284
}
@@ -268,7 +293,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
268293

269294
if (Agents.Count < MaxAgents) ShowAllAgents = false;
270295

271-
var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Gray);
296+
var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Offline);
272297
if (firstOnlineAgent is null)
273298
_hasExpandedAgent = false;
274299
if (!_hasExpandedAgent && firstOnlineAgent is not null)
@@ -433,7 +458,7 @@ private static bool ShouldShowDummy(Workspace workspace)
433458
case Workspace.Types.Status.Stopping:
434459
case Workspace.Types.Status.Stopped:
435460
return true;
436-
// TODO: should we include and show a different color than Gray for workspaces that are
461+
// TODO: should we include and show a different color than Offline for workspaces that are
437462
// failed, canceled or deleting?
438463
default:
439464
return false;

App/Views/Pages/TrayWindowMainPage.xaml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,22 +137,27 @@
137137
x:Key="StatusColor"
138138
SelectedKey="{x:Bind Path=ConnectionStatus, Mode=OneWay}">
139139

140-
<converters:StringToBrushSelectorItem>
140+
<converters:StringToBrushSelectorItem Key="Offline">
141141
<converters:StringToBrushSelectorItem.Value>
142142
<SolidColorBrush Color="#8e8e93" />
143143
</converters:StringToBrushSelectorItem.Value>
144144
</converters:StringToBrushSelectorItem>
145-
<converters:StringToBrushSelectorItem Key="Red">
145+
<converters:StringToBrushSelectorItem Key="NoRecentHandshake">
146146
<converters:StringToBrushSelectorItem.Value>
147147
<SolidColorBrush Color="#ff3b30" />
148148
</converters:StringToBrushSelectorItem.Value>
149149
</converters:StringToBrushSelectorItem>
150-
<converters:StringToBrushSelectorItem Key="Yellow">
150+
<converters:StringToBrushSelectorItem Key="Unhealthy">
151151
<converters:StringToBrushSelectorItem.Value>
152152
<SolidColorBrush Color="#ffcc01" />
153153
</converters:StringToBrushSelectorItem.Value>
154154
</converters:StringToBrushSelectorItem>
155-
<converters:StringToBrushSelectorItem Key="Green">
155+
<converters:StringToBrushSelectorItem Key="Connecting">
156+
<converters:StringToBrushSelectorItem.Value>
157+
<SolidColorBrush Color="#ffcc01" />
158+
</converters:StringToBrushSelectorItem.Value>
159+
</converters:StringToBrushSelectorItem>
160+
<converters:StringToBrushSelectorItem Key="Healthy">
156161
<converters:StringToBrushSelectorItem.Value>
157162
<SolidColorBrush Color="#34c759" />
158163
</converters:StringToBrushSelectorItem.Value>
@@ -189,6 +194,7 @@
189194
HorizontalAlignment="Right"
190195
VerticalAlignment="Center"
191196
Height="14" Width="14"
197+
ToolTipService.ToolTip="{x:Bind ConnectionTooltip, Mode=OneWay}"
192198
Margin="0,1,0,0">
193199

194200
<Ellipse

Vpn.Proto/vpn.proto

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ option go_package = "github.com/coder/coder/v2/vpn";
33
option csharp_namespace = "Coder.Desktop.Vpn.Proto";
44

55
import "google/protobuf/timestamp.proto";
6+
import "google/protobuf/duration.proto";
67

78
package vpn;
89

@@ -48,10 +49,10 @@ message TunnelMessage {
4849
message ClientMessage {
4950
RPC rpc = 1;
5051
oneof msg {
51-
StartRequest start = 2;
52-
StopRequest stop = 3;
53-
StatusRequest status = 4;
54-
}
52+
StartRequest start = 2;
53+
StopRequest stop = 3;
54+
StatusRequest status = 4;
55+
}
5556
}
5657

5758
// ServiceMessage is a message from the service (to the client). Windows only.
@@ -131,6 +132,21 @@ message Agent {
131132
// last_handshake is the primary indicator of whether we are connected to a peer. Zero value or
132133
// anything longer than 5 minutes ago means there is a problem.
133134
google.protobuf.Timestamp last_handshake = 6;
135+
// If unset, a successful ping has not yet been made.
136+
optional LastPing last_ping = 7;
137+
}
138+
139+
message LastPing {
140+
// latency is the RTT of the ping to the agent.
141+
google.protobuf.Duration latency = 1;
142+
// did_p2p indicates whether the ping was sent P2P, or over DERP.
143+
bool did_p2p = 2;
144+
// preferred_derp is the human readable name of the preferred DERP region,
145+
// or the region used for the last ping, if it was sent over DERP.
146+
string preferred_derp = 3;
147+
// preferred_derp_latency is the last known latency to the preferred DERP
148+
// region. Unset if the region does not appear in the DERP map.
149+
optional google.protobuf.Duration preferred_derp_latency = 4;
134150
}
135151

136152
// NetworkSettingsRequest is based on

0 commit comments

Comments
 (0)