From b8d7993f506854cb4b850ccf79bb1cf913fd71d2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:07:31 +0200 Subject: [PATCH 1/2] chore: added latency tooltips on workspaces (#134) Fixes: #106 ![image](https://github.com/user-attachments/assets/a522bfba-adbe-46cd-a261-9275c83fb27c) ![image](https://github.com/user-attachments/assets/fe544382-cd28-4187-a806-af821043502f) --- App/ViewModels/AgentViewModel.cs | 143 ++++++++++++++++++++---- App/ViewModels/TrayWindowViewModel.cs | 41 +++++-- App/Views/Pages/TrayWindowMainPage.xaml | 14 ++- Vpn.Proto/vpn.proto | 24 +++- 4 files changed, 187 insertions(+), 35 deletions(-) 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"> Date: Tue, 1 Jul 2025 19:09:57 +0200 Subject: [PATCH 2/2] chore: add connect/disconnect notifications on Coder Connect (#140) --- App/App.xaml.cs | 12 +++++++- App/Services/UserNotifier.cs | 41 +++++++++++++++++++++---- App/Views/TrayWindow.xaml.cs | 59 ++++++++++++++++++++++++++++++++++-- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/App/App.xaml.cs b/App/App.xaml.cs index f4c05a2..3165e2f 100644 --- a/App/App.xaml.cs +++ b/App/App.xaml.cs @@ -27,7 +27,7 @@ namespace Coder.Desktop.App; -public partial class App : Application, IDispatcherQueueManager +public partial class App : Application, IDispatcherQueueManager, INotificationHandler { private const string MutagenControllerConfigSection = "MutagenController"; private const string UpdaterConfigSection = "Updater"; @@ -91,6 +91,7 @@ public App() services.AddSingleton(); services.AddSingleton(_ => this); + services.AddSingleton(_ => this); services.AddSingleton(_ => new WindowsCredentialBackend(WindowsCredentialBackend.CoderCredentialsTargetName)); services.AddSingleton(); @@ -335,4 +336,13 @@ public void RunInUiThread(DispatcherQueueHandler action) } dispatcherQueue.TryEnqueue(action); } + + public void HandleNotificationActivation(IDictionary args) + { + var app = (App)Current; + if (app != null && app.TrayWindow != null) + { + app.TrayWindow.Tray_Open(); + } + } } diff --git a/App/Services/UserNotifier.cs b/App/Services/UserNotifier.cs index 5ad8e38..e759c50 100644 --- a/App/Services/UserNotifier.cs +++ b/App/Services/UserNotifier.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Coder.Desktop.App.Views; using Microsoft.Extensions.Logging; using Microsoft.Windows.AppNotifications; using Microsoft.Windows.AppNotifications.Builder; @@ -20,17 +21,40 @@ public interface IUserNotifier : INotificationHandler, IAsyncDisposable public void UnregisterHandler(string name); public Task ShowErrorNotification(string title, string message, CancellationToken ct = default); - public Task ShowActionNotification(string title, string message, string handlerName, IDictionary? args = null, CancellationToken ct = default); + /// + /// This method allows to display a Windows-native notification with an action defined in + /// and provided . + /// + /// Title of the notification. + /// Message to be displayed in the notification body. + /// Handler should be e.g. nameof(Handler) where Handler + /// implements . + /// If handler is null the action will open Coder Desktop. + /// Arguments to be provided to the handler when executing the action. + public Task ShowActionNotification(string title, string message, string? handlerName, IDictionary? args = null, CancellationToken ct = default); } -public class UserNotifier(ILogger logger, IDispatcherQueueManager dispatcherQueueManager) : IUserNotifier +public class UserNotifier : IUserNotifier { private const string CoderNotificationHandler = "CoderNotificationHandler"; + private const string DefaultNotificationHandler = "DefaultNotificationHandler"; private readonly AppNotificationManager _notificationManager = AppNotificationManager.Default; + private readonly ILogger _logger; + private readonly IDispatcherQueueManager _dispatcherQueueManager; private ConcurrentDictionary Handlers { get; } = new(); + public UserNotifier(ILogger logger, IDispatcherQueueManager dispatcherQueueManager, + INotificationHandler notificationHandler) + { + _logger = logger; + _dispatcherQueueManager = dispatcherQueueManager; + var defaultHandlerAdded = Handlers.TryAdd(DefaultNotificationHandler, notificationHandler); + if (!defaultHandlerAdded) + throw new Exception($"UserNotifier failed to be initialized with {nameof(DefaultNotificationHandler)}"); + } + public ValueTask DisposeAsync() { return ValueTask.CompletedTask; @@ -50,6 +74,8 @@ public void RegisterHandler(string name, INotificationHandler handler) public void UnregisterHandler(string name) { + if (name == nameof(DefaultNotificationHandler)) + throw new InvalidOperationException($"You cannot remove '{name}'."); if (!Handlers.TryRemove(name, out _)) throw new InvalidOperationException($"No handler with the name '{name}' is registered."); } @@ -61,8 +87,11 @@ public Task ShowErrorNotification(string title, string message, CancellationToke return Task.CompletedTask; } - public Task ShowActionNotification(string title, string message, string handlerName, IDictionary? args = null, CancellationToken ct = default) + public Task ShowActionNotification(string title, string message, string? handlerName, IDictionary? args = null, CancellationToken ct = default) { + if (handlerName == null) + handlerName = nameof(DefaultNotificationHandler); // Use default handler if no handler name is provided + if (!Handlers.TryGetValue(handlerName, out _)) throw new InvalidOperationException($"No action handler with the name '{handlerName}' is registered."); @@ -90,11 +119,11 @@ public void HandleNotificationActivation(IDictionary args) if (!Handlers.TryGetValue(handlerName, out var handler)) { - logger.LogWarning("no action handler '{HandlerName}' found for notification activation, ignoring", handlerName); + _logger.LogWarning("no action handler '{HandlerName}' found for notification activation, ignoring", handlerName); return; } - dispatcherQueueManager.RunInUiThread(() => + _dispatcherQueueManager.RunInUiThread(() => { try { @@ -102,7 +131,7 @@ public void HandleNotificationActivation(IDictionary args) } catch (Exception ex) { - logger.LogWarning(ex, "could not handle activation for notification with handler '{HandlerName}", handlerName); + _logger.LogWarning(ex, "could not handle activation for notification with handler '{HandlerName}", handlerName); } }); } diff --git a/App/Views/TrayWindow.xaml.cs b/App/Views/TrayWindow.xaml.cs index 6131e25..7269e68 100644 --- a/App/Views/TrayWindow.xaml.cs +++ b/App/Views/TrayWindow.xaml.cs @@ -9,9 +9,12 @@ using Microsoft.UI.Windowing; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; using Microsoft.UI.Xaml.Media.Animation; using System; +using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using Windows.Graphics; using Windows.System; @@ -33,17 +36,23 @@ public sealed partial class TrayWindow : Window private int _lastWindowHeight; private Storyboard? _currentSb; + private VpnLifecycle curVpnLifecycle = VpnLifecycle.Stopped; + private RpcLifecycle curRpcLifecycle = RpcLifecycle.Disconnected; + private readonly IRpcController _rpcController; private readonly ICredentialManager _credentialManager; private readonly ISyncSessionController _syncSessionController; private readonly IUpdateController _updateController; + private readonly IUserNotifier _userNotifier; private readonly TrayWindowLoadingPage _loadingPage; private readonly TrayWindowDisconnectedPage _disconnectedPage; private readonly TrayWindowLoginRequiredPage _loginRequiredPage; private readonly TrayWindowMainPage _mainPage; - public TrayWindow(IRpcController rpcController, ICredentialManager credentialManager, + public TrayWindow( + IRpcController rpcController, ICredentialManager credentialManager, ISyncSessionController syncSessionController, IUpdateController updateController, + IUserNotifier userNotifier, TrayWindowLoadingPage loadingPage, TrayWindowDisconnectedPage disconnectedPage, TrayWindowLoginRequiredPage loginRequiredPage, TrayWindowMainPage mainPage) @@ -52,6 +61,7 @@ public TrayWindow(IRpcController rpcController, ICredentialManager credentialMan _credentialManager = credentialManager; _syncSessionController = syncSessionController; _updateController = updateController; + _userNotifier = userNotifier; _loadingPage = loadingPage; _disconnectedPage = disconnectedPage; _loginRequiredPage = loginRequiredPage; @@ -142,9 +152,54 @@ private void SetPageByState(RpcModel rpcModel, CredentialModel credentialModel, } } + private void MaybeNotifyUser(RpcModel rpcModel) + { + // This method is called when the state changes, but we don't want to notify + // the user if the state hasn't changed. + var isRpcLifecycleChanged = rpcModel.RpcLifecycle == RpcLifecycle.Disconnected && curRpcLifecycle != rpcModel.RpcLifecycle; + var isVpnLifecycleChanged = (rpcModel.VpnLifecycle == VpnLifecycle.Started || rpcModel.VpnLifecycle == VpnLifecycle.Stopped) && curVpnLifecycle != rpcModel.VpnLifecycle; + + if (!isRpcLifecycleChanged && !isVpnLifecycleChanged) + { + return; + } + + var oldRpcLifeycle = curRpcLifecycle; + var oldVpnLifecycle = curVpnLifecycle; + curRpcLifecycle = rpcModel.RpcLifecycle; + curVpnLifecycle = rpcModel.VpnLifecycle; + + var messages = new List(); + + if (oldRpcLifeycle != RpcLifecycle.Disconnected && curRpcLifecycle == RpcLifecycle.Disconnected) + { + messages.Add("Disconnected from Coder background service."); + } + + if (oldVpnLifecycle != curVpnLifecycle) + { + switch (curVpnLifecycle) + { + case VpnLifecycle.Started: + messages.Add("Coder Connect started."); + break; + case VpnLifecycle.Stopped: + messages.Add("Coder Connect stopped."); + break; + } + } + + if (messages.Count == 0) return; + if (_aw.IsVisible) return; + + var message = string.Join(" ", messages); + _userNotifier.ShowActionNotification(message, string.Empty, null, null, CancellationToken.None); + } + private void RpcController_StateChanged(object? _, RpcModel model) { SetPageByState(model, _credentialManager.GetCachedCredentials(), _syncSessionController.GetState()); + MaybeNotifyUser(model); } private void CredentialManager_CredentialsChanged(object? _, CredentialModel model) @@ -297,7 +352,7 @@ private void Window_Activated(object sender, WindowActivatedEventArgs e) } [RelayCommand] - private void Tray_Open() + public void Tray_Open() { MoveResizeAndActivate(); }