diff --git a/App/ViewModels/AgentViewModel.cs b/App/ViewModels/AgentViewModel.cs index cd5907b..0cf2651 100644 --- a/App/ViewModels/AgentViewModel.cs +++ b/App/ViewModels/AgentViewModel.cs @@ -1,11 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.ComponentModel; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Windows.ApplicationModel.DataTransfer; using Coder.Desktop.App.Services; using Coder.Desktop.App.Utils; using Coder.Desktop.CoderSdk; @@ -18,15 +10,24 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls.Primitives; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using Windows.ApplicationModel.DataTransfer; namespace Coder.Desktop.App.ViewModels; public interface IAgentViewModelFactory { public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, - string hostnameSuffix, - AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName); - + string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, + string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, DateTime? lastHandshake); public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, string hostnameSuffix, AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName); @@ -40,7 +41,9 @@ public class AgentViewModelFactory( { public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName, string hostnameSuffix, - AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName) + AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, + string? workspaceName, bool? didP2p, string? preferredDerp, TimeSpan? latency, TimeSpan? preferredDerpLatency, + DateTime? lastHandshake) { return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory, expanderHost, id) @@ -51,6 +54,11 @@ public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fu ConnectionStatus = connectionStatus, DashboardBaseUrl = dashboardBaseUrl, WorkspaceName = workspaceName, + DidP2p = didP2p, + PreferredDerp = preferredDerp, + Latency = latency, + PreferredDerpLatency = preferredDerpLatency, + LastHandshake = lastHandshake, }; } @@ -73,10 +81,25 @@ public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id, public enum AgentConnectionStatus { - Green, - Yellow, - Red, - Gray, + Healthy, + Connecting, + Unhealthy, + NoRecentHandshake, + Offline +} + +public static class AgentConnectionStatusExtensions +{ + public static string ToDisplayString(this AgentConnectionStatus status) => + status switch + { + AgentConnectionStatus.Healthy => "Healthy", + AgentConnectionStatus.Connecting => "Connecting", + AgentConnectionStatus.Unhealthy => "High latency", + AgentConnectionStatus.NoRecentHandshake => "No recent handshake", + AgentConnectionStatus.Offline => "Offline", + _ => status.ToString() + }; } public partial class AgentViewModel : ObservableObject, IModelUpdateable @@ -160,6 +183,7 @@ public string FullyQualifiedDomainName [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))] [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] public required partial AgentConnectionStatus ConnectionStatus { get; set; } [ObservableProperty] @@ -182,6 +206,77 @@ public string FullyQualifiedDomainName [NotifyPropertyChangedFor(nameof(ExpandAppsMessage))] public partial bool AppFetchErrored { get; set; } = false; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial bool? DidP2p { get; set; } = false; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial string? PreferredDerp { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial TimeSpan? Latency { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial TimeSpan? PreferredDerpLatency { get; set; } = null; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ConnectionTooltip))] + public partial DateTime? LastHandshake { get; set; } = null; + + public string ConnectionTooltip + { + get + { + var description = new StringBuilder(); + var highLatencyWarning = ConnectionStatus == AgentConnectionStatus.Unhealthy ? $"({AgentConnectionStatus.Unhealthy.ToDisplayString()})" : ""; + + if (DidP2p != null && DidP2p.Value && Latency != null) + { + description.Append($""" + You're connected peer-to-peer. {highLatencyWarning} + + You ↔ {Latency.Value.Milliseconds} ms ↔ {WorkspaceName} + """ + ); + } + else if (Latency != null) + { + description.Append($""" + You're connected through a DERP relay. {highLatencyWarning} + We'll switch over to peer-to-peer when available. + + Total latency: {Latency.Value.Milliseconds} ms + """ + ); + + if (PreferredDerpLatency != null) + { + description.Append($"\nYou ↔ {PreferredDerp}: {PreferredDerpLatency.Value.Milliseconds} ms"); + + var derpToWorkspaceEstimatedLatency = Latency - PreferredDerpLatency; + + // Guard against negative values if the two readings were taken at different times + if (derpToWorkspaceEstimatedLatency > TimeSpan.Zero) + { + description.Append($"\n{PreferredDerp} ms ↔ {WorkspaceName}: {derpToWorkspaceEstimatedLatency.Value.Milliseconds} ms"); + } + } + } + else + { + description.Append(ConnectionStatus.ToDisplayString()); + } + if (LastHandshake != null) + description.Append($"\n\nLast handshake: {LastHandshake?.ToString()}"); + + return description.ToString().TrimEnd('\n', ' '); ; + } + } + + // We only show 6 apps max, which fills the entire width of the tray // window. public IEnumerable VisibleApps => Apps.Count > MaxAppsPerRow ? Apps.Take(MaxAppsPerRow) : Apps; @@ -192,7 +287,7 @@ public string? ExpandAppsMessage { get { - if (ConnectionStatus == AgentConnectionStatus.Gray) + if (ConnectionStatus == AgentConnectionStatus.Offline) return "Your workspace is offline."; if (FetchingApps && Apps.Count == 0) // Don't show this message if we have any apps already. When @@ -285,6 +380,16 @@ public bool TryApplyChanges(AgentViewModel model) DashboardBaseUrl = model.DashboardBaseUrl; if (WorkspaceName != model.WorkspaceName) WorkspaceName = model.WorkspaceName; + if (DidP2p != model.DidP2p) + DidP2p = model.DidP2p; + if (PreferredDerp != model.PreferredDerp) + PreferredDerp = model.PreferredDerp; + if (Latency != model.Latency) + Latency = model.Latency; + if (PreferredDerpLatency != model.PreferredDerpLatency) + PreferredDerpLatency = model.PreferredDerpLatency; + if (LastHandshake != model.LastHandshake) + LastHandshake = model.LastHandshake; // Apps are not set externally. @@ -307,7 +412,7 @@ public void SetExpanded(bool expanded) partial void OnConnectionStatusChanged(AgentConnectionStatus oldValue, AgentConnectionStatus newValue) { - if (IsExpanded && newValue is not AgentConnectionStatus.Gray) FetchApps(); + if (IsExpanded && newValue is not AgentConnectionStatus.Offline) FetchApps(); } private void FetchApps() @@ -316,7 +421,7 @@ private void FetchApps() FetchingApps = true; // If the workspace is off, then there's no agent and there's no apps. - if (ConnectionStatus == AgentConnectionStatus.Gray) + if (ConnectionStatus == AgentConnectionStatus.Offline) { FetchingApps = false; Apps.Clear(); diff --git a/App/ViewModels/TrayWindowViewModel.cs b/App/ViewModels/TrayWindowViewModel.cs index 8540453..f57947d 100644 --- a/App/ViewModels/TrayWindowViewModel.cs +++ b/App/ViewModels/TrayWindowViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Linq; +using System.Security.Principal; using System.Threading.Tasks; using Coder.Desktop.App.Models; using Coder.Desktop.App.Services; @@ -29,6 +30,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost { private const int MaxAgents = 5; private const string DefaultDashboardUrl = "https://coder.com"; + private readonly TimeSpan HealthyPingThreshold = TimeSpan.FromMilliseconds(150); private readonly IServiceProvider _services; private readonly IRpcController _rpcController; @@ -222,10 +224,28 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (string.IsNullOrWhiteSpace(fqdn)) continue; - var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); - var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5) - ? AgentConnectionStatus.Green - : AgentConnectionStatus.Yellow; + var connectionStatus = AgentConnectionStatus.Healthy; + + if (agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default && agent.LastHandshake.ToDateTime() < DateTime.UtcNow) + { + // For compatibility with older deployments, we assume that if the + // last ping is null, the agent is healthy. + var isLatencyAcceptable = agent.LastPing == null || agent.LastPing.Latency.ToTimeSpan() < HealthyPingThreshold; + + var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime()); + + if (lastHandshakeAgo > TimeSpan.FromMinutes(5)) + connectionStatus = AgentConnectionStatus.NoRecentHandshake; + else if (!isLatencyAcceptable) + connectionStatus = AgentConnectionStatus.Unhealthy; + } + else + { + // If the last handshake is not correct (null, default or in the future), + // we assume the agent is connecting (yellow status icon). + connectionStatus = AgentConnectionStatus.Connecting; + } + workspacesWithAgents.Add(agent.WorkspaceId); var workspace = rpcModel.Workspaces.FirstOrDefault(w => w.Id == agent.WorkspaceId); @@ -236,7 +256,12 @@ private void UpdateFromRpcModel(RpcModel rpcModel) _hostnameSuffixGetter.GetCachedSuffix(), connectionStatus, credentialModel.CoderUrl, - workspace?.Name)); + workspace?.Name, + agent.LastPing?.DidP2P, + agent.LastPing?.PreferredDerp, + agent.LastPing?.Latency?.ToTimeSpan(), + agent.LastPing?.PreferredDerpLatency?.ToTimeSpan(), + agent.LastHandshake != null && agent.LastHandshake.ToDateTime() != default ? agent.LastHandshake?.ToDateTime() : null)); } // For every stopped workspace that doesn't have any agents, add a @@ -253,7 +278,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) // conflict with any agent IDs. uuid, _hostnameSuffixGetter.GetCachedSuffix(), - AgentConnectionStatus.Gray, + AgentConnectionStatus.Offline, credentialModel.CoderUrl, workspace.Name)); } @@ -268,7 +293,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel) if (Agents.Count < MaxAgents) ShowAllAgents = false; - var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Gray); + var firstOnlineAgent = agents.FirstOrDefault(a => a.ConnectionStatus != AgentConnectionStatus.Offline); if (firstOnlineAgent is null) _hasExpandedAgent = false; if (!_hasExpandedAgent && firstOnlineAgent is not null) @@ -433,7 +458,7 @@ private static bool ShouldShowDummy(Workspace workspace) case Workspace.Types.Status.Stopping: case Workspace.Types.Status.Stopped: return true; - // TODO: should we include and show a different color than Gray for workspaces that are + // TODO: should we include and show a different color than Offline for workspaces that are // failed, canceled or deleting? default: return false; diff --git a/App/Views/Pages/TrayWindowMainPage.xaml b/App/Views/Pages/TrayWindowMainPage.xaml index 80f557d..3cc21de 100644 --- a/App/Views/Pages/TrayWindowMainPage.xaml +++ b/App/Views/Pages/TrayWindowMainPage.xaml @@ -137,22 +137,27 @@ x:Key="StatusColor" SelectedKey="{x:Bind Path=ConnectionStatus, Mode=OneWay}"> - + - + - + - + + + + + + @@ -189,6 +194,7 @@ HorizontalAlignment="Right" VerticalAlignment="Center" Height="14" Width="14" + ToolTipService.ToolTip="{x:Bind ConnectionTooltip, Mode=OneWay}" Margin="0,1,0,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