Skip to content

chore: make hostname suffix mutable in views #102

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 82 additions & 16 deletions App/ViewModels/AgentViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,13 @@ namespace Coder.Desktop.App.ViewModels;

public interface IAgentViewModelFactory
{
public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix,
public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName,
string hostnameSuffix,
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName);

public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id,
string hostnameSuffix,
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName);
}

public class AgentViewModelFactory(
Expand All @@ -33,14 +38,32 @@ public class AgentViewModelFactory(
ICredentialManager credentialManager,
IAgentAppViewModelFactory agentAppViewModelFactory) : IAgentViewModelFactory
{
public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string hostname, string hostnameSuffix,
public AgentViewModel Create(IAgentExpanderHost expanderHost, Uuid id, string fullyQualifiedDomainName,
string hostnameSuffix,
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string? workspaceName)
{
return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory,
expanderHost, id)
{
Hostname = hostname,
HostnameSuffix = hostnameSuffix,
ConfiguredFqdn = fullyQualifiedDomainName,
ConfiguredHostname = string.Empty,
ConfiguredHostnameSuffix = hostnameSuffix,
ConnectionStatus = connectionStatus,
DashboardBaseUrl = dashboardBaseUrl,
WorkspaceName = workspaceName,
};
}

public AgentViewModel CreateDummy(IAgentExpanderHost expanderHost, Uuid id,
string hostnameSuffix,
AgentConnectionStatus connectionStatus, Uri dashboardBaseUrl, string workspaceName)
{
return new AgentViewModel(childLogger, coderApiClientFactory, credentialManager, agentAppViewModelFactory,
expanderHost, id)
{
ConfiguredFqdn = string.Empty,
ConfiguredHostname = workspaceName,
ConfiguredHostnameSuffix = hostnameSuffix,
ConnectionStatus = connectionStatus,
DashboardBaseUrl = dashboardBaseUrl,
WorkspaceName = workspaceName,
Expand Down Expand Up @@ -84,15 +107,55 @@ public partial class AgentViewModel : ObservableObject, IModelUpdateable<AgentVi

public readonly Uuid Id;

// This is set only for "dummy" agents that represent unstarted workspaces. If set, then ConfiguredFqdn
// should be empty, otherwise it will override this.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ViewableHostname))]
[NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))]
[NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))]
public required partial string ConfiguredHostname { get; set; }

// This should be set if we have an FQDN from the VPN service, and overrides ConfiguredHostname if set.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullHostname))]
public required partial string Hostname { get; set; }
[NotifyPropertyChangedFor(nameof(ViewableHostname))]
[NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))]
[NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))]
public required partial string ConfiguredFqdn { get; set; }

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullHostname))]
public required partial string HostnameSuffix { get; set; } // including leading dot
[NotifyPropertyChangedFor(nameof(ViewableHostname))]
[NotifyPropertyChangedFor(nameof(ViewableHostnameSuffix))]
[NotifyPropertyChangedFor(nameof(FullyQualifiedDomainName))]
public required partial string ConfiguredHostnameSuffix { get; set; } // including leading dot


public string FullyQualifiedDomainName
{
get
{
if (!string.IsNullOrEmpty(ConfiguredFqdn)) return ConfiguredFqdn;
return ConfiguredHostname + ConfiguredHostnameSuffix;
}
}

public string FullHostname => Hostname + HostnameSuffix;
/// <summary>
/// ViewableHostname is the hostname portion of the fully qualified domain name (FQDN) specifically for
/// views that render it differently than the suffix. If the ConfiguredHostnameSuffix doesn't actually
/// match the FQDN, then this will be the entire FQDN, and ViewableHostnameSuffix will be empty.
/// </summary>
public string ViewableHostname => !FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix)
? FullyQualifiedDomainName
: FullyQualifiedDomainName[0..^ConfiguredHostnameSuffix.Length];

/// <summary>
/// ViewableHostnameSuffix is the domain suffix portion (including leading dot) of the fully qualified
/// domain name (FQDN) specifically for views that render it differently from the rest of the FQDN. If
/// the ConfiguredHostnameSuffix doesn't actually match the FQDN, then this will be empty and the
/// ViewableHostname will contain the entire FQDN.
/// </summary>
public string ViewableHostnameSuffix => FullyQualifiedDomainName.EndsWith(ConfiguredHostnameSuffix)
? ConfiguredHostnameSuffix
: string.Empty;

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowExpandAppsMessage))]
Expand Down Expand Up @@ -202,10 +265,12 @@ public bool TryApplyChanges(AgentViewModel model)

// To avoid spurious UI updates which cause flashing, don't actually
// write to values unless they've changed.
if (Hostname != model.Hostname)
Hostname = model.Hostname;
if (HostnameSuffix != model.HostnameSuffix)
HostnameSuffix = model.HostnameSuffix;
if (ConfiguredFqdn != model.ConfiguredFqdn)
ConfiguredFqdn = model.ConfiguredFqdn;
if (ConfiguredHostname != model.ConfiguredHostname)
ConfiguredHostname = model.ConfiguredHostname;
if (ConfiguredHostnameSuffix != model.ConfiguredHostnameSuffix)
ConfiguredHostnameSuffix = model.ConfiguredHostnameSuffix;
if (ConnectionStatus != model.ConnectionStatus)
ConnectionStatus = model.ConnectionStatus;
if (DashboardBaseUrl != model.DashboardBaseUrl)
Expand Down Expand Up @@ -337,12 +402,13 @@ private void ContinueFetchApps(Task<WorkspaceAgent> task)
{
Scheme = scheme,
Host = "vscode-remote",
Path = $"/ssh-remote+{FullHostname}/{workspaceAgent.ExpandedDirectory}",
Path = $"/ssh-remote+{FullyQualifiedDomainName}/{workspaceAgent.ExpandedDirectory}",
}.Uri;
}
catch (Exception e)
{
_logger.LogWarning(e, "Could not craft app URI for display app {displayApp}, app will not appear in list",
_logger.LogWarning(e,
"Could not craft app URI for display app {displayApp}, app will not appear in list",
displayApp);
continue;
}
Expand All @@ -365,7 +431,7 @@ private void CopyHostname(object parameter)
{
RequestedOperation = DataPackageOperation.Copy,
};
dataPackage.SetText(FullHostname);
dataPackage.SetText(FullyQualifiedDomainName);
Clipboard.SetContent(dataPackage);

if (parameter is not FrameworkElement frameworkElement) return;
Expand Down
24 changes: 8 additions & 16 deletions App/ViewModels/TrayWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost
{
private const int MaxAgents = 5;
private const string DefaultDashboardUrl = "https://coder.com";
private const string DefaultHostnameSuffix = ".coder";

private readonly IServiceProvider _services;
private readonly IRpcController _rpcController;
Expand Down Expand Up @@ -90,6 +91,8 @@ public partial class TrayWindowViewModel : ObservableObject, IAgentExpanderHost

[ObservableProperty] public partial string DashboardUrl { get; set; } = DefaultDashboardUrl;

private string _hostnameSuffix = DefaultHostnameSuffix;

public TrayWindowViewModel(IServiceProvider services, IRpcController rpcController,
ICredentialManager credentialManager, IAgentViewModelFactory agentViewModelFactory)
{
Expand Down Expand Up @@ -181,14 +184,6 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
if (string.IsNullOrWhiteSpace(fqdn))
continue;

var fqdnPrefix = fqdn;
var fqdnSuffix = "";
if (fqdn.Contains('.'))
{
fqdnPrefix = fqdn[..fqdn.LastIndexOf('.')];
fqdnSuffix = fqdn[fqdn.LastIndexOf('.')..];
}

var lastHandshakeAgo = DateTime.UtcNow.Subtract(agent.LastHandshake.ToDateTime());
var connectionStatus = lastHandshakeAgo < TimeSpan.FromMinutes(5)
? AgentConnectionStatus.Green
Expand All @@ -199,8 +194,8 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
agents.Add(_agentViewModelFactory.Create(
this,
uuid,
fqdnPrefix,
fqdnSuffix,
fqdn,
_hostnameSuffix,
connectionStatus,
credentialModel.CoderUrl,
workspace?.Name));
Expand All @@ -214,15 +209,12 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
if (!Uuid.TryFrom(workspace.Id.Span, out var uuid))
continue;

agents.Add(_agentViewModelFactory.Create(
agents.Add(_agentViewModelFactory.CreateDummy(
this,
// Workspace ID is fine as a stand-in here, it shouldn't
// conflict with any agent IDs.
uuid,
// We assume that it's a single-agent workspace.
workspace.Name,
// TODO: this needs to get the suffix from the server
".coder",
_hostnameSuffix,
AgentConnectionStatus.Gray,
credentialModel.CoderUrl,
workspace.Name));
Expand All @@ -233,7 +225,7 @@ private void UpdateFromRpcModel(RpcModel rpcModel)
{
if (a.ConnectionStatus != b.ConnectionStatus)
return a.ConnectionStatus.CompareTo(b.ConnectionStatus);
return string.Compare(a.FullHostname, b.FullHostname, StringComparison.Ordinal);
return string.Compare(a.FullyQualifiedDomainName, b.FullyQualifiedDomainName, StringComparison.Ordinal);
});

if (Agents.Count < MaxAgents) ShowAllAgents = false;
Expand Down
4 changes: 2 additions & 2 deletions App/Views/Pages/TrayWindowMainPage.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,9 @@
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap">

<Run Text="{x:Bind Hostname, Mode=OneWay}"
<Run Text="{x:Bind ViewableHostname, Mode=OneWay}"
Foreground="{ThemeResource DefaultTextForegroundThemeBrush}" />
<Run Text="{x:Bind HostnameSuffix, Mode=OneWay}"
<Run Text="{x:Bind ViewableHostnameSuffix, Mode=OneWay}"
Foreground="{ThemeResource SystemControlForegroundBaseMediumBrush}" />
</TextBlock>

Expand Down
14 changes: 7 additions & 7 deletions App/Views/Pages/TrayWindowMainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,22 @@ public TrayWindowMainPage(TrayWindowViewModel viewModel)
}

// HACK: using XAML to populate the text Runs results in an additional
// whitespace Run being inserted between the Hostname and the
// HostnameSuffix. You might think, "OK let's populate the entire TextBlock
// content from code then!", but this results in the ItemsRepeater
// whitespace Run being inserted between the ViewableHostname and the
// ViewableHostnameSuffix. You might think, "OK let's populate the entire
// TextBlock content from code then!", but this results in the ItemsRepeater
// corrupting it and firing events off to the wrong AgentModel.
//
// This is the best solution I came up with that worked.
public void AgentHostnameText_OnLoaded(object sender, RoutedEventArgs e)
{
if (sender is not TextBlock textBlock) return;

var nonEmptyRuns = new List<Run>();
var nonWhitespaceRuns = new List<Run>();
foreach (var inline in textBlock.Inlines)
if (inline is Run run && !string.IsNullOrWhiteSpace(run.Text))
nonEmptyRuns.Add(run);
if (inline is Run run && run.Text != " ")
nonWhitespaceRuns.Add(run);

textBlock.Inlines.Clear();
foreach (var run in nonEmptyRuns) textBlock.Inlines.Add(run);
foreach (var run in nonWhitespaceRuns) textBlock.Inlines.Add(run);
}
}
Loading