feat(configmanager): add ConnectionStrings editor with test connection support

Adds a new ConnectionStrings section to ConfigManager allowing users to manage
database connection strings with provider selection, connection testing, and
visual feedback for connection state.
This commit is contained in:
Joseph Doherty
2026-01-22 11:12:08 -05:00
parent 9bf0c29add
commit db663cc82d
20 changed files with 2508 additions and 2 deletions
@@ -74,6 +74,9 @@ public partial class App : Avalonia.Application
// Runtime Validation Services
services.AddSingleton<IRuntimeConfigValidationService, RuntimeConfigValidationService>();
// Connection Testing
services.AddSingleton<IConnectionTestService, ConnectionTestService>();
// ViewModels
services.AddTransient<MainWindowViewModel>();
}
@@ -0,0 +1,57 @@
using System.Globalization;
using Avalonia.Data.Converters;
using JdeScoping.ConfigManager.Models;
namespace JdeScoping.ConfigManager.Converters;
/// <summary>
/// Converts a ConnectionProvider value to visibility (bool) based on whether it matches the target provider.
/// </summary>
public class ProviderToVisibilityConverter : IValueConverter
{
/// <summary>
/// Converter instance for SqlServer provider visibility.
/// </summary>
public static readonly ProviderToVisibilityConverter SqlServer = new(ConnectionProvider.SqlServer);
/// <summary>
/// Converter instance for Oracle provider visibility.
/// </summary>
public static readonly ProviderToVisibilityConverter Oracle = new(ConnectionProvider.Oracle);
/// <summary>
/// Converter instance for Generic provider visibility.
/// </summary>
public static readonly ProviderToVisibilityConverter Generic = new(ConnectionProvider.Generic);
private readonly ConnectionProvider _targetProvider;
/// <summary>
/// Initializes a new instance of the <see cref="ProviderToVisibilityConverter"/> class.
/// </summary>
/// <param name="targetProvider">The provider to match for visibility.</param>
public ProviderToVisibilityConverter(ConnectionProvider targetProvider)
{
_targetProvider = targetProvider;
}
/// <summary>
/// Converts a ConnectionProvider to a boolean indicating visibility.
/// </summary>
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is ConnectionProvider provider)
{
return provider == _targetProvider;
}
return false;
}
/// <summary>
/// Not implemented - this is a one-way converter.
/// </summary>
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
@@ -17,6 +17,7 @@
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.2.*" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.*" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="DiffPlex" Version="1.7.*" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.*" />
<PackageReference Include="MessageBox.Avalonia" Version="3.1.*" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.*" />
@@ -40,7 +40,7 @@ public class ConfigModel
/// <summary>
/// Gets or sets the connection strings for external data sources.
/// </summary>
public Dictionary<string, string> ConnectionStrings { get; set; } = new();
public ConnectionStringsSection ConnectionStrings { get; set; } = new();
/// <summary>
/// Gets or sets the secure store configuration.
@@ -0,0 +1,11 @@
namespace JdeScoping.ConfigManager.Models;
/// <summary>
/// Database provider types supported by the ConnectionStrings editor.
/// </summary>
public enum ConnectionProvider
{
Generic,
SqlServer,
Oracle
}
@@ -0,0 +1,86 @@
namespace JdeScoping.ConfigManager.Models;
/// <summary>
/// Represents a single connection string entry with provider-specific fields.
/// </summary>
public class ConnectionStringEntry
{
public string Name { get; set; } = string.Empty;
public ConnectionProvider Provider { get; set; } = ConnectionProvider.Generic;
// SqlServer fields
public string? Server { get; set; }
public string? Database { get; set; }
public string? UserId { get; set; }
public string? Password { get; set; }
public string Encrypt { get; set; } = "True";
public bool TrustServerCertificate { get; set; }
public int ConnectionTimeout { get; set; } = 30;
public string? ApplicationName { get; set; }
// Oracle fields
public string? Host { get; set; }
public int Port { get; set; } = 1521;
public string? ServiceName { get; set; }
// Generic fields
public string? RawConnectionString { get; set; }
/// <summary>
/// Generates the connection string based on the Provider type.
/// </summary>
public string GenerateConnectionString()
{
return Provider switch
{
ConnectionProvider.SqlServer => GenerateSqlServerConnectionString(),
ConnectionProvider.Oracle => GenerateOracleConnectionString(),
ConnectionProvider.Generic => RawConnectionString ?? string.Empty,
_ => string.Empty
};
}
private string GenerateSqlServerConnectionString()
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(Server))
parts.Add($"Server={Server}");
if (!string.IsNullOrWhiteSpace(Database))
parts.Add($"Database={Database}");
if (!string.IsNullOrWhiteSpace(UserId))
parts.Add($"User Id={UserId}");
if (!string.IsNullOrWhiteSpace(Password))
parts.Add($"Password={Password}");
if (!string.IsNullOrWhiteSpace(Encrypt))
parts.Add($"Encrypt={Encrypt}");
if (TrustServerCertificate)
parts.Add("TrustServerCertificate=True");
if (ConnectionTimeout != 30)
parts.Add($"Connection Timeout={ConnectionTimeout}");
if (!string.IsNullOrWhiteSpace(ApplicationName))
parts.Add($"Application Name={ApplicationName}");
return string.Join(";", parts);
}
private string GenerateOracleConnectionString()
{
var parts = new List<string>();
var host = Host ?? "localhost";
var port = Port > 0 ? Port : 1521;
var service = ServiceName ?? "";
parts.Add($"Data Source=//{host}:{port}/{service}");
if (!string.IsNullOrWhiteSpace(UserId))
parts.Add($"User Id={UserId}");
if (!string.IsNullOrWhiteSpace(Password))
parts.Add($"Password={Password}");
if (ConnectionTimeout > 0 && ConnectionTimeout != 30)
parts.Add($"Connection Timeout={ConnectionTimeout}");
return string.Join(";", parts);
}
}
@@ -0,0 +1,9 @@
namespace JdeScoping.ConfigManager.Models;
/// <summary>
/// Configuration section for connection strings.
/// </summary>
public class ConnectionStringsSection
{
public List<ConnectionStringEntry> Entries { get; set; } = new();
}
@@ -0,0 +1,97 @@
using System.Diagnostics;
using JdeScoping.ConfigManager.Models;
using Microsoft.Data.SqlClient;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Service for testing database connections.
/// </summary>
public class ConnectionTestService : IConnectionTestService
{
private const int ConnectionTimeoutSeconds = 10;
public async Task<ConnectionTestResult> TestConnectionAsync(
string connectionString,
ConnectionProvider provider,
CancellationToken cancellationToken = default)
{
return provider switch
{
ConnectionProvider.SqlServer => await TestSqlServerConnectionAsync(connectionString, cancellationToken).ConfigureAwait(false),
ConnectionProvider.Oracle => new ConnectionTestResult
{
Success = false,
Message = "Oracle connection testing not implemented"
},
ConnectionProvider.Generic => new ConnectionTestResult
{
Success = false,
Message = "Cannot test generic connection strings"
},
_ => new ConnectionTestResult
{
Success = false,
Message = $"Unknown provider: {provider}"
}
};
}
private static async Task<ConnectionTestResult> TestSqlServerConnectionAsync(
string connectionString,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Ensure connection timeout is set
var builder = new SqlConnectionStringBuilder(connectionString)
{
ConnectTimeout = ConnectionTimeoutSeconds
};
await using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
stopwatch.Stop();
return new ConnectionTestResult
{
Success = true,
Message = "Connection successful",
Duration = stopwatch.Elapsed
};
}
catch (OperationCanceledException)
{
stopwatch.Stop();
return new ConnectionTestResult
{
Success = false,
Message = "Connection test was cancelled",
Duration = stopwatch.Elapsed
};
}
catch (SqlException ex)
{
stopwatch.Stop();
return new ConnectionTestResult
{
Success = false,
Message = $"SQL Server error: {ex.Message}",
Duration = stopwatch.Elapsed
};
}
catch (Exception ex)
{
stopwatch.Stop();
return new ConnectionTestResult
{
Success = false,
Message = $"Connection failed: {ex.Message}",
Duration = stopwatch.Elapsed
};
}
}
}
@@ -0,0 +1,21 @@
using JdeScoping.ConfigManager.Models;
namespace JdeScoping.ConfigManager.Services;
/// <summary>
/// Result of testing a database connection.
/// </summary>
public class ConnectionTestResult
{
public bool Success { get; init; }
public string Message { get; init; } = string.Empty;
public TimeSpan? Duration { get; init; }
}
/// <summary>
/// Service for testing database connections.
/// </summary>
public interface IConnectionTestService
{
Task<ConnectionTestResult> TestConnectionAsync(string connectionString, ConnectionProvider provider, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,325 @@
using System.Windows.Input;
using JdeScoping.ConfigManager.Models;
namespace JdeScoping.ConfigManager.ViewModels.Forms;
/// <summary>
/// ViewModel for editing a single connection string entry.
/// </summary>
public class ConnectionStringEntryViewModel : ViewModelBase
{
private readonly ConnectionStringEntry _model;
private readonly Action _onChanged;
private bool _isPasswordVisible;
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStringEntryViewModel"/> class.
/// </summary>
/// <param name="model">The connection string entry model.</param>
/// <param name="onChanged">The action to invoke when any property changes.</param>
public ConnectionStringEntryViewModel(ConnectionStringEntry model, Action onChanged)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
_onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
TogglePasswordVisibilityCommand = new RelayCommand(() => IsPasswordVisible = !IsPasswordVisible);
}
/// <summary>
/// Gets or sets the connection string name.
/// </summary>
public string Name
{
get => _model.Name;
set
{
if (_model.Name != value)
{
_model.Name = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the database provider type.
/// </summary>
public ConnectionProvider Provider
{
get => _model.Provider;
set
{
if (_model.Provider != value)
{
_model.Provider = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ProviderDisplay));
OnPropertyChanged(nameof(ServerDisplay));
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the SQL Server server name.
/// </summary>
public string? Server
{
get => _model.Server;
set
{
if (_model.Server != value)
{
_model.Server = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ServerDisplay));
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the database name.
/// </summary>
public string? Database
{
get => _model.Database;
set
{
if (_model.Database != value)
{
_model.Database = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the user ID.
/// </summary>
public string? UserId
{
get => _model.UserId;
set
{
if (_model.UserId != value)
{
_model.UserId = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the password.
/// </summary>
public string? Password
{
get => _model.Password;
set
{
if (_model.Password != value)
{
_model.Password = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the encrypt setting for SQL Server.
/// </summary>
public string Encrypt
{
get => _model.Encrypt;
set
{
if (_model.Encrypt != value)
{
_model.Encrypt = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets whether to trust the server certificate for SQL Server.
/// </summary>
public bool TrustServerCertificate
{
get => _model.TrustServerCertificate;
set
{
if (_model.TrustServerCertificate != value)
{
_model.TrustServerCertificate = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the connection timeout in seconds.
/// </summary>
public int ConnectionTimeout
{
get => _model.ConnectionTimeout;
set
{
if (_model.ConnectionTimeout != value)
{
_model.ConnectionTimeout = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the application name for SQL Server.
/// </summary>
public string? ApplicationName
{
get => _model.ApplicationName;
set
{
if (_model.ApplicationName != value)
{
_model.ApplicationName = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the Oracle host name.
/// </summary>
public string? Host
{
get => _model.Host;
set
{
if (_model.Host != value)
{
_model.Host = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ServerDisplay));
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the Oracle port number.
/// </summary>
public int Port
{
get => _model.Port;
set
{
if (_model.Port != value)
{
_model.Port = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the Oracle service name.
/// </summary>
public string? ServiceName
{
get => _model.ServiceName;
set
{
if (_model.ServiceName != value)
{
_model.ServiceName = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets the raw connection string for generic providers.
/// </summary>
public string? RawConnectionString
{
get => _model.RawConnectionString;
set
{
if (_model.RawConnectionString != value)
{
_model.RawConnectionString = value;
OnPropertyChanged();
OnPropertyChanged(nameof(GeneratedConnectionString));
_onChanged();
}
}
}
/// <summary>
/// Gets or sets whether the password is visible.
/// </summary>
public bool IsPasswordVisible
{
get => _isPasswordVisible;
set
{
if (_isPasswordVisible != value)
{
_isPasswordVisible = value;
OnPropertyChanged();
}
}
}
/// <summary>
/// Gets the command to toggle password visibility.
/// </summary>
public ICommand TogglePasswordVisibilityCommand { get; }
/// <summary>
/// Gets the generated connection string based on current property values.
/// </summary>
public string GeneratedConnectionString => _model.GenerateConnectionString();
/// <summary>
/// Gets the display string for the provider type.
/// </summary>
public string ProviderDisplay => Provider.ToString();
/// <summary>
/// Gets the server/host display value based on provider type.
/// Returns Server for SqlServer, Host for Oracle, or "-" for Generic.
/// </summary>
public string ServerDisplay => Provider switch
{
ConnectionProvider.SqlServer => Server ?? string.Empty,
ConnectionProvider.Oracle => Host ?? string.Empty,
_ => "-"
};
}
@@ -0,0 +1,260 @@
using System.Collections.ObjectModel;
using System.Windows.Input;
using JdeScoping.ConfigManager.Models;
using JdeScoping.ConfigManager.Services;
namespace JdeScoping.ConfigManager.ViewModels.Forms;
/// <summary>
/// ViewModel for editing the ConnectionStrings configuration section.
/// Manages a collection of connection string entries with add, delete, validate, and test commands.
/// </summary>
public class ConnectionStringsFormViewModel : ViewModelBase
{
private readonly ConnectionStringsSection _model;
private readonly Action _onChanged;
private readonly IDialogService _dialogService;
private readonly IConnectionTestService _connectionTestService;
private ConnectionStringEntryViewModel? _selectedConnection;
private bool _isTesting;
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStringsFormViewModel"/> class.
/// </summary>
/// <param name="model">The connection strings section model.</param>
/// <param name="onChanged">The action to invoke when any property changes.</param>
/// <param name="dialogService">The dialog service for showing messages and confirmations.</param>
/// <param name="connectionTestService">The service for testing database connections.</param>
public ConnectionStringsFormViewModel(
ConnectionStringsSection model,
Action onChanged,
IDialogService dialogService,
IConnectionTestService connectionTestService)
{
_model = model ?? throw new ArgumentNullException(nameof(model));
_onChanged = onChanged ?? throw new ArgumentNullException(nameof(onChanged));
_dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService));
_connectionTestService = connectionTestService ?? throw new ArgumentNullException(nameof(connectionTestService));
Connections = new ObservableCollection<ConnectionStringEntryViewModel>();
// Initialize view models from model entries
foreach (var entry in _model.Entries)
{
Connections.Add(new ConnectionStringEntryViewModel(entry, _onChanged));
}
// Initialize commands
AddConnectionCommand = new RelayCommand(AddConnection);
DeleteConnectionCommand = new AsyncRelayCommand(DeleteConnectionAsync, () => HasSelection);
ValidateConnectionCommand = new AsyncRelayCommand(ValidateConnectionAsync, () => HasSelection);
TestConnectionCommand = new AsyncRelayCommand(TestConnectionAsync, () => HasSelection && !IsTesting);
}
/// <summary>
/// Gets the collection of connection string entry view models.
/// </summary>
public ObservableCollection<ConnectionStringEntryViewModel> Connections { get; }
/// <summary>
/// Gets or sets the currently selected connection.
/// </summary>
public ConnectionStringEntryViewModel? SelectedConnection
{
get => _selectedConnection;
set
{
if (SetProperty(ref _selectedConnection, value))
{
OnPropertyChanged(nameof(HasSelection));
RaiseCommandsCanExecuteChanged();
}
}
}
/// <summary>
/// Gets a value indicating whether a connection is selected.
/// </summary>
public bool HasSelection => SelectedConnection != null;
/// <summary>
/// Gets a value indicating whether a connection test is in progress.
/// </summary>
public bool IsTesting
{
get => _isTesting;
private set
{
if (SetProperty(ref _isTesting, value))
{
RaiseCommandsCanExecuteChanged();
}
}
}
/// <summary>
/// Gets the number of connections in the collection.
/// </summary>
public int ConnectionCount => Connections.Count;
/// <summary>
/// Gets the list of available connection providers.
/// </summary>
public static IReadOnlyList<ConnectionProvider> AvailableProviders { get; } =
Enum.GetValues<ConnectionProvider>().ToList().AsReadOnly();
/// <summary>
/// Gets the list of available encrypt options for SQL Server connections.
/// </summary>
public static IReadOnlyList<string> EncryptOptions { get; } =
new List<string> { "True", "False", "Strict" }.AsReadOnly();
/// <summary>
/// Gets the command for adding a new connection.
/// </summary>
public ICommand AddConnectionCommand { get; }
/// <summary>
/// Gets the command for deleting the selected connection.
/// </summary>
public ICommand DeleteConnectionCommand { get; }
/// <summary>
/// Gets the command for validating the selected connection string.
/// </summary>
public ICommand ValidateConnectionCommand { get; }
/// <summary>
/// Gets the command for testing the selected connection.
/// </summary>
public ICommand TestConnectionCommand { get; }
/// <summary>
/// Adds a new connection entry with default values.
/// </summary>
private void AddConnection()
{
var entry = new ConnectionStringEntry
{
Name = "NewConnection"
};
_model.Entries.Add(entry);
var viewModel = new ConnectionStringEntryViewModel(entry, _onChanged);
Connections.Add(viewModel);
SelectedConnection = viewModel;
OnPropertyChanged(nameof(ConnectionCount));
_onChanged();
}
/// <summary>
/// Deletes the selected connection after user confirmation.
/// </summary>
private async Task DeleteConnectionAsync()
{
if (SelectedConnection == null)
return;
var name = SelectedConnection.Name;
var confirmed = await _dialogService.ShowConfirmationAsync(
"Delete Connection",
$"Delete connection '{name}'?");
if (!confirmed)
return;
// Find the model entry to remove
var modelEntry = _model.Entries.FirstOrDefault(e => e.Name == name);
if (modelEntry != null)
{
_model.Entries.Remove(modelEntry);
}
Connections.Remove(SelectedConnection);
SelectedConnection = null;
OnPropertyChanged(nameof(ConnectionCount));
_onChanged();
}
/// <summary>
/// Validates that the selected connection has a non-empty generated connection string.
/// </summary>
private async Task ValidateConnectionAsync()
{
if (SelectedConnection == null)
return;
var connectionString = SelectedConnection.GeneratedConnectionString;
if (string.IsNullOrWhiteSpace(connectionString))
{
await _dialogService.ShowMessageAsync(
"Validation Failed",
$"Connection '{SelectedConnection.Name}' has an empty connection string. Please configure the connection properties.");
}
else
{
await _dialogService.ShowMessageAsync(
"Validation Passed",
$"Connection '{SelectedConnection.Name}' has a valid connection string format.");
}
}
/// <summary>
/// Tests the selected connection by attempting to connect to the database.
/// </summary>
private async Task TestConnectionAsync()
{
if (SelectedConnection == null)
return;
var connectionString = SelectedConnection.GeneratedConnectionString;
if (string.IsNullOrWhiteSpace(connectionString))
{
await _dialogService.ShowMessageAsync(
"Test Connection",
"Cannot test connection: connection string is empty. Please configure the connection properties first.");
return;
}
IsTesting = true;
try
{
var result = await _connectionTestService.TestConnectionAsync(
connectionString,
SelectedConnection.Provider);
if (result.Success)
{
var durationText = result.Duration.HasValue
? $" ({result.Duration.Value.TotalMilliseconds:F0}ms)"
: "";
await _dialogService.ShowMessageAsync(
"Connection Successful",
$"Successfully connected to '{SelectedConnection.Name}'{durationText}.");
}
else
{
await _dialogService.ShowMessageAsync(
"Connection Failed",
$"Failed to connect to '{SelectedConnection.Name}':\n\n{result.Message}");
}
}
finally
{
IsTesting = false;
}
}
/// <summary>
/// Raises CanExecuteChanged for all commands that depend on selection state.
/// </summary>
private void RaiseCommandsCanExecuteChanged()
{
(DeleteConnectionCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
(ValidateConnectionCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
(TestConnectionCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
}
}
@@ -25,6 +25,7 @@ public class MainWindowViewModel : ViewModelBase
private readonly ISecureStoreManager _secureStoreManager;
private readonly IClipboardService _clipboardService;
private readonly IRuntimeConfigValidationService _runtimeValidationService;
private readonly IConnectionTestService _connectionTestService;
private readonly ILogger<MainWindowViewModel>? _logger;
private string _configFolderPath = "No folder selected";
@@ -203,6 +204,7 @@ public class MainWindowViewModel : ViewModelBase
ISecureStoreManager secureStoreManager,
IClipboardService clipboardService,
IRuntimeConfigValidationService runtimeValidationService,
IConnectionTestService connectionTestService,
ILogger<MainWindowViewModel>? logger)
{
_fileSystem = fileSystem;
@@ -214,6 +216,7 @@ public class MainWindowViewModel : ViewModelBase
_secureStoreManager = secureStoreManager;
_clipboardService = clipboardService;
_runtimeValidationService = runtimeValidationService;
_connectionTestService = connectionTestService;
_logger = logger;
OpenFolderCommand = new AsyncRelayCommand(OpenFolderAsync);
@@ -257,6 +260,7 @@ public class MainWindowViewModel : ViewModelBase
new SecureStoreManager(),
new NullClipboardService(),
new RuntimeConfigValidationService(new SecureStoreManager()),
new ConnectionTestService(),
null)
{
}
@@ -457,6 +461,7 @@ public class MainWindowViewModel : ViewModelBase
settingsFolder.Children.Add(new TreeNodeViewModel("Ldap", "👥", TreeNodeType.SettingsSection) { SectionKey = "Ldap" });
settingsFolder.Children.Add(new TreeNodeViewModel("Search", "🔍", TreeNodeType.SettingsSection) { SectionKey = "Search" });
settingsFolder.Children.Add(new TreeNodeViewModel("ExcelExport", "📊", TreeNodeType.SettingsSection) { SectionKey = "ExcelExport" });
settingsFolder.Children.Add(new TreeNodeViewModel("ConnectionStrings", "🔗", TreeNodeType.SettingsSection) { SectionKey = "ConnectionStrings" });
TreeNodes.Add(settingsFolder);
// Pipelines folder
@@ -563,6 +568,11 @@ public class MainWindowViewModel : ViewModelBase
"Ldap" => new LdapFormViewModel(_appSettings.Ldap, MarkAsChanged),
"Search" => new SearchFormViewModel(_appSettings.Search, MarkAsChanged),
"ExcelExport" => new ExcelExportFormViewModel(_appSettings.ExcelExport, MarkAsChanged),
"ConnectionStrings" when _dialogService != null => new ConnectionStringsFormViewModel(
_appSettings.ConnectionStrings,
MarkAsChanged,
_dialogService,
_connectionTestService),
_ when _selectedNode.NodeType == TreeNodeType.Pipeline && _pipelines != null
=> _pipelines.Pipelines.TryGetValue(_selectedNode.SectionKey!, out var pipeline)
? new PipelineEditorViewModel(_selectedNode.SectionKey!, pipeline, GetAvailableConnections(), MarkAsChanged)
@@ -0,0 +1,421 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:JdeScoping.ConfigManager.ViewModels.Forms"
xmlns:models="using:JdeScoping.ConfigManager.Models"
xmlns:local="using:JdeScoping.ConfigManager.Converters"
x:Class="JdeScoping.ConfigManager.Views.Forms.ConnectionStringsFormView"
x:DataType="vm:ConnectionStringsFormViewModel">
<UserControl.Resources>
<!-- SqlServer Provider Template -->
<DataTemplate x:Key="SqlServerTemplate" x:DataType="vm:ConnectionStringEntryViewModel">
<StackPanel Spacing="16">
<!-- Server & Database Row -->
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Server" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding Server}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="localhost\SQLEXPRESS"/>
<TextBlock Text="Server name or IP with optional instance"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Database" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding Database}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="ScopingTool"/>
</StackPanel>
</Grid>
<!-- UserId & Password Row -->
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="User Id" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding UserId}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="sa"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Password" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
Text="{Binding Password}"
PasswordChar="•"
RevealPassword="{Binding IsPasswordVisible}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"/>
<Button Grid.Column="1"
Command="{Binding TogglePasswordVisibilityCommand}"
Background="#3D4550" Foreground="#E6EDF5"
Width="36" Height="36" Margin="8,0,0,0"
CornerRadius="4" Padding="0"
ToolTip.Tip="Show/Hide password">
<TextBlock Text="👁" FontSize="14" HorizontalAlignment="Center"/>
</Button>
</Grid>
</StackPanel>
</Grid>
<!-- Encrypt & TrustServerCertificate Row -->
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Encrypt" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<ComboBox ItemsSource="{Binding $parent[UserControl].((vm:ConnectionStringsFormViewModel)DataContext).EncryptOptions}"
SelectedItem="{Binding Encrypt}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
HorizontalAlignment="Stretch"/>
<TextBlock Text="Options: True, False, Strict"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text=" " FontSize="12"/>
<CheckBox IsChecked="{Binding TrustServerCertificate}"
Foreground="#E6EDF5" Height="36"
VerticalContentAlignment="Center">
<TextBlock Text="Trust Server Certificate" Foreground="#E6EDF5"/>
</CheckBox>
<TextBlock Text="Skip certificate validation (dev only)"
Foreground="#FF6B6B" FontSize="11"/>
</StackPanel>
</Grid>
<!-- Timeout & ApplicationName Row -->
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Connection Timeout (seconds)" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<NumericUpDown Value="{Binding ConnectionTimeout}"
Minimum="1" Maximum="600"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"/>
<TextBlock Text="Default: 30 seconds"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Application Name" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding ApplicationName}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="JdeScopingTool"/>
<TextBlock Text="Identifies app in SQL Server logs"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</Grid>
<!-- Connection String Preview -->
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="12" Margin="0,8,0,0">
<StackPanel Spacing="8">
<TextBlock Text="CONNECTION STRING PREVIEW"
Foreground="#5C6A7A" FontSize="11" FontWeight="Medium"/>
<TextBlock Text="{Binding GeneratedConnectionString}"
Foreground="#9BA8B8" FontSize="11"
FontFamily="JetBrains Mono"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</DataTemplate>
<!-- Oracle Provider Template -->
<DataTemplate x:Key="OracleTemplate" x:DataType="vm:ConnectionStringEntryViewModel">
<StackPanel Spacing="16">
<!-- Host & Port Row -->
<Grid ColumnDefinitions="*,16,120">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Host" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding Host}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="oracle-server.company.com"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Port" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<NumericUpDown Value="{Binding Port}"
Minimum="1" Maximum="65535"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"/>
<TextBlock Text="Default: 1521"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</Grid>
<!-- Service Name -->
<StackPanel Spacing="4">
<TextBlock Text="Service Name" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding ServiceName}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="JDEPROD"/>
<TextBlock Text="Oracle service name (not SID)"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
<!-- UserId & Password Row -->
<Grid ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="User Id" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding UserId}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="jde_readonly"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Password" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
Text="{Binding Password}"
PasswordChar="•"
RevealPassword="{Binding IsPasswordVisible}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"/>
<Button Grid.Column="1"
Command="{Binding TogglePasswordVisibilityCommand}"
Background="#3D4550" Foreground="#E6EDF5"
Width="36" Height="36" Margin="8,0,0,0"
CornerRadius="4" Padding="0"
ToolTip.Tip="Show/Hide password">
<TextBlock Text="👁" FontSize="14" HorizontalAlignment="Center"/>
</Button>
</Grid>
</StackPanel>
</Grid>
<!-- Connection Timeout -->
<StackPanel Spacing="4" MaxWidth="200" HorizontalAlignment="Left">
<TextBlock Text="Connection Timeout (seconds)" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<NumericUpDown Value="{Binding ConnectionTimeout}"
Minimum="1" Maximum="600"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"/>
</StackPanel>
<!-- Connection String Preview -->
<Border Background="#151920" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="4" Padding="12" Margin="0,8,0,0">
<StackPanel Spacing="8">
<TextBlock Text="CONNECTION STRING PREVIEW"
Foreground="#5C6A7A" FontSize="11" FontWeight="Medium"/>
<TextBlock Text="{Binding GeneratedConnectionString}"
Foreground="#9BA8B8" FontSize="11"
FontFamily="JetBrains Mono"
TextWrapping="Wrap"/>
</StackPanel>
</Border>
</StackPanel>
</DataTemplate>
<!-- Generic Provider Template -->
<DataTemplate x:Key="GenericTemplate" x:DataType="vm:ConnectionStringEntryViewModel">
<StackPanel Spacing="16">
<!-- Info Banner -->
<Border Background="#1A2233" BorderBrush="#3B82F6" BorderThickness="1"
CornerRadius="4" Padding="12">
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock Text="" FontSize="16" Foreground="#3B82F6" VerticalAlignment="Center"/>
<TextBlock Text="Use Generic for unsupported providers or custom connection strings"
Foreground="#9BA8B8" FontSize="12" TextWrapping="Wrap"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
<!-- Connection String TextArea -->
<StackPanel Spacing="4">
<TextBlock Text="Connection String" Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding RawConnectionString}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550"
FontFamily="JetBrains Mono" FontSize="12"
AcceptsReturn="True"
TextWrapping="NoWrap"
MinHeight="120"
Watermark="Data Source=myserver;Initial Catalog=mydb;..."/>
<TextBlock Text="Enter the full connection string"
Foreground="#5C6A7A" FontSize="11"/>
</StackPanel>
</StackPanel>
</DataTemplate>
</UserControl.Resources>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="24" MaxWidth="800">
<!-- Header -->
<StackPanel>
<TextBlock Text="Connection Strings"
Foreground="#E6EDF5" FontSize="18" FontWeight="SemiBold"/>
<Border Height="1" Background="#2D3540" Margin="0,12,0,0"/>
</StackPanel>
<!-- Connections List Section -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16">
<StackPanel Spacing="12">
<!-- Section Header with Count Badge -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Connections" Foreground="#E6EDF5"
FontWeight="SemiBold" FontSize="14"
VerticalAlignment="Center"/>
<Border Background="#3D4550" CornerRadius="10"
Padding="8,2" VerticalAlignment="Center">
<TextBlock Text="{Binding ConnectionCount}"
Foreground="#9BA8B8" FontSize="11"
FontFamily="JetBrains Mono"/>
</Border>
</StackPanel>
<!-- DataGrid -->
<DataGrid ItemsSource="{Binding Connections}"
SelectedItem="{Binding SelectedConnection}"
SelectionMode="Single"
Height="200"
Background="#0D0F12"
RowBackground="#0D0F12"
BorderBrush="#2D3540"
BorderThickness="1"
GridLinesVisibility="Horizontal"
HorizontalGridLinesBrush="#2D3540"
HeadersVisibility="Column"
AutoGenerateColumns="False"
CanUserReorderColumns="False"
CanUserResizeColumns="True"
CanUserSortColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding Name}"
Width="*"
IsReadOnly="True"
Foreground="#E6EDF5"/>
<DataGridTextColumn Header="Provider"
Binding="{Binding ProviderDisplay}"
Width="100"
IsReadOnly="True"
Foreground="#9BA8B8"/>
<DataGridTextColumn Header="Server"
Binding="{Binding ServerDisplay}"
Width="150"
IsReadOnly="True"
Foreground="#9BA8B8"/>
</DataGrid.Columns>
</DataGrid>
<!-- Toolbar -->
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding AddConnectionCommand}"
Background="#3B82F6" Foreground="White"
Padding="12,6" CornerRadius="4">
<TextBlock Text="Add" FontWeight="Medium"/>
</Button>
<Button Command="{Binding DeleteConnectionCommand}"
Background="#DC2626" Foreground="White"
Padding="12,6" CornerRadius="4">
<TextBlock Text="Delete" FontWeight="Medium"/>
</Button>
</StackPanel>
</StackPanel>
</Border>
<!-- Placeholder Section (when no connection is selected) -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="48"
IsVisible="{Binding !HasSelection}">
<TextBlock Text="Select a connection string to edit"
Foreground="#5C6A7A" FontSize="14"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- Edit Form Section (when a connection is selected) -->
<Border Background="#0D0F12" BorderBrush="#2D3540" BorderThickness="1"
CornerRadius="6" Padding="16"
IsVisible="{Binding HasSelection}">
<StackPanel Spacing="16">
<!-- Edit Header -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Edit Connection:" Foreground="#E6EDF5"
FontWeight="SemiBold" FontSize="14"
VerticalAlignment="Center"/>
<TextBlock Text="{Binding SelectedConnection.Name}"
Foreground="#3B82F6" FontSize="14"
FontFamily="JetBrains Mono"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Name and Provider Fields -->
<Grid ColumnDefinitions="*,16,*">
<!-- Name -->
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Name"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<TextBox Text="{Binding SelectedConnection.Name}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
FontFamily="JetBrains Mono"
Watermark="ConnectionName"/>
</StackPanel>
<!-- Provider -->
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Provider"
Foreground="#9BA8B8" FontSize="12" FontWeight="Medium"/>
<ComboBox ItemsSource="{Binding AvailableProviders}"
SelectedItem="{Binding SelectedConnection.Provider}"
Background="#232A35" Foreground="#E6EDF5"
BorderBrush="#3D4550" Height="36"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<!-- Separator -->
<Border Height="1" Background="#2D3540" Margin="0,8"/>
<!-- Provider-specific fields - SqlServer -->
<ContentControl Content="{Binding SelectedConnection}"
ContentTemplate="{StaticResource SqlServerTemplate}"
IsVisible="{Binding SelectedConnection.Provider, Converter={x:Static local:ProviderToVisibilityConverter.SqlServer}}"/>
<!-- Provider-specific fields - Oracle -->
<ContentControl Content="{Binding SelectedConnection}"
ContentTemplate="{StaticResource OracleTemplate}"
IsVisible="{Binding SelectedConnection.Provider, Converter={x:Static local:ProviderToVisibilityConverter.Oracle}}"/>
<!-- Provider-specific fields - Generic -->
<ContentControl Content="{Binding SelectedConnection}"
ContentTemplate="{StaticResource GenericTemplate}"
IsVisible="{Binding SelectedConnection.Provider, Converter={x:Static local:ProviderToVisibilityConverter.Generic}}"/>
</StackPanel>
</Border>
<!-- Action Buttons Section (when a connection is selected) -->
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right"
IsVisible="{Binding HasSelection}">
<Button Command="{Binding ValidateConnectionCommand}"
Background="#3D4550" Foreground="#E6EDF5"
Padding="16,8" CornerRadius="4">
<TextBlock Text="Validate" FontWeight="Medium"/>
</Button>
<Button Command="{Binding TestConnectionCommand}"
Background="#10B981" Foreground="White"
Padding="16,8" CornerRadius="4">
<TextBlock Text="Test Connection" FontWeight="Medium"/>
</Button>
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace JdeScoping.ConfigManager.Views.Forms;
public partial class ConnectionStringsFormView : UserControl
{
public ConnectionStringsFormView()
{
InitializeComponent();
}
}