7c4781dfe3
Extract shared models, services, and application logic into JdeScoping.ConfigManager.Core library. Add JdeScoping.ConfigManager.Cli console app with validate, test-connection, and secret commands using System.CommandLine. UI project now references Core for platform-agnostic functionality while retaining Avalonia-specific dialog and clipboard services.
1164 lines
41 KiB
C#
1164 lines
41 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Windows.Input;
|
|
using Avalonia.Media;
|
|
using JdeScoping.ConfigManager.Core.Constants;
|
|
using JdeScoping.ConfigManager.Core.Models;
|
|
using JdeScoping.ConfigManager.Core.Services;
|
|
using JdeScoping.ConfigManager.Core.Services.SecureStore;
|
|
using JdeScoping.ConfigManager.Services;
|
|
using JdeScoping.ConfigManager.ViewModels.Dialogs;
|
|
using JdeScoping.ConfigManager.ViewModels.Forms;
|
|
using JdeScoping.DataSync.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace JdeScoping.ConfigManager.ViewModels;
|
|
|
|
/// <summary>
|
|
/// Main window view model.
|
|
/// </summary>
|
|
public class MainWindowViewModel : ViewModelBase
|
|
{
|
|
private readonly IFileSystem _fileSystem;
|
|
private readonly IConfigFileService _configFileService;
|
|
private readonly IValidationService _validationService;
|
|
private readonly IBackupService _backupService;
|
|
private readonly IAutoDiscoveryService _autoDiscoveryService;
|
|
private readonly IDialogService? _dialogService;
|
|
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";
|
|
private bool _hasUnsavedChanges;
|
|
private string _validationStatus = "Valid";
|
|
private IBrush _validationStatusColor = Brushes.LightGreen;
|
|
private TreeNodeViewModel? _selectedNode;
|
|
private object? _selectedFormViewModel;
|
|
|
|
private ConfigModel? _appSettings;
|
|
private Dictionary<string, EtlPipelineConfig>? _pipelines;
|
|
|
|
/// <summary>
|
|
/// Gets or sets the currently loaded configuration folder path.
|
|
/// </summary>
|
|
public string ConfigFolderPath
|
|
{
|
|
get => _configFolderPath;
|
|
set => SetProperty(ref _configFolderPath, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets a value indicating whether there are unsaved configuration changes.
|
|
/// </summary>
|
|
public bool HasUnsavedChanges
|
|
{
|
|
get => _hasUnsavedChanges;
|
|
set => SetProperty(ref _hasUnsavedChanges, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the validation status message displayed to the user.
|
|
/// </summary>
|
|
public string ValidationStatus
|
|
{
|
|
get => _validationStatus;
|
|
set => SetProperty(ref _validationStatus, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the brush color for the validation status indicator.
|
|
/// </summary>
|
|
public IBrush ValidationStatusColor
|
|
{
|
|
get => _validationStatusColor;
|
|
set => SetProperty(ref _validationStatusColor, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the currently selected tree node in the configuration tree view.
|
|
/// </summary>
|
|
public TreeNodeViewModel? SelectedNode
|
|
{
|
|
get => _selectedNode;
|
|
set
|
|
{
|
|
if (SetProperty(ref _selectedNode, value))
|
|
OnSelectedNodeChanged();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets or sets the view model for the form displayed when a node is selected.
|
|
/// </summary>
|
|
public object? SelectedFormViewModel
|
|
{
|
|
get => _selectedFormViewModel;
|
|
set => SetProperty(ref _selectedFormViewModel, value);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the collection of tree nodes representing the configuration structure.
|
|
/// </summary>
|
|
public ObservableCollection<TreeNodeViewModel> TreeNodes { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Gets the command for opening a configuration folder.
|
|
/// </summary>
|
|
public ICommand OpenFolderCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for saving configuration changes.
|
|
/// </summary>
|
|
public ICommand SaveCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for exiting the application.
|
|
/// </summary>
|
|
public ICommand ExitCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for undoing the last configuration change.
|
|
/// </summary>
|
|
public ICommand UndoCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for redoing the last undone configuration change.
|
|
/// </summary>
|
|
public ICommand RedoCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for validating the current configuration.
|
|
/// </summary>
|
|
public ICommand ValidateCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for testing database connections.
|
|
/// </summary>
|
|
public ICommand TestConnectionCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for creating a new secure store.
|
|
/// </summary>
|
|
public ICommand NewStoreCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for adding an existing secure store.
|
|
/// </summary>
|
|
public ICommand AddExistingStoreCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for saving a secure store.
|
|
/// </summary>
|
|
public ICommand SaveStoreCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for generating a new key file.
|
|
/// </summary>
|
|
public ICommand GenerateKeyFileCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for adding a secret to the current store.
|
|
/// </summary>
|
|
public ICommand AddSecretCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for deleting a secret from the current store.
|
|
/// </summary>
|
|
public ICommand DeleteSecretCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for adding a new pipeline.
|
|
/// </summary>
|
|
public ICommand AddPipelineCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for deleting the selected pipeline.
|
|
/// </summary>
|
|
public ICommand DeletePipelineCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the command for validating runtime configuration using Infrastructure validators.
|
|
/// </summary>
|
|
public ICommand ValidateRuntimeConfigCommand { get; }
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
|
|
/// </summary>
|
|
/// <param name="fileSystem">File system abstraction for I/O operations.</param>
|
|
/// <param name="configFileService">Service for loading and saving configuration files.</param>
|
|
/// <param name="validationService">Service for validating configuration settings.</param>
|
|
/// <param name="backupService">Service for creating configuration backups.</param>
|
|
/// <param name="autoDiscoveryService">Service for discovering configuration folder locations.</param>
|
|
/// <param name="dialogService">Service for showing platform dialogs.</param>
|
|
/// <param name="secureStoreManager">Service for managing encrypted secret stores.</param>
|
|
/// <param name="clipboardService">Service for clipboard operations.</param>
|
|
/// <param name="runtimeValidationService">Service for runtime configuration validation.</param>
|
|
/// <param name="connectionTestService">Service for testing database connections.</param>
|
|
/// <param name="logger">Optional logger for recording view model activities.</param>
|
|
public MainWindowViewModel(
|
|
IFileSystem fileSystem,
|
|
IConfigFileService configFileService,
|
|
IValidationService validationService,
|
|
IBackupService backupService,
|
|
IAutoDiscoveryService autoDiscoveryService,
|
|
IDialogService? dialogService,
|
|
ISecureStoreManager secureStoreManager,
|
|
IClipboardService clipboardService,
|
|
IRuntimeConfigValidationService runtimeValidationService,
|
|
IConnectionTestService connectionTestService,
|
|
ILogger<MainWindowViewModel>? logger)
|
|
{
|
|
_fileSystem = fileSystem;
|
|
_configFileService = configFileService;
|
|
_validationService = validationService;
|
|
_backupService = backupService;
|
|
_autoDiscoveryService = autoDiscoveryService;
|
|
_dialogService = dialogService;
|
|
_secureStoreManager = secureStoreManager;
|
|
_clipboardService = clipboardService;
|
|
_runtimeValidationService = runtimeValidationService;
|
|
_connectionTestService = connectionTestService;
|
|
_logger = logger;
|
|
|
|
OpenFolderCommand = new AsyncRelayCommand(OpenFolderAsync);
|
|
SaveCommand = new AsyncRelayCommand(SaveAsync, () => HasUnsavedChanges);
|
|
ExitCommand = new RelayCommand(() => Environment.Exit(0));
|
|
UndoCommand = new RelayCommand(() => { }, () => false); // TODO: Implement undo/redo
|
|
RedoCommand = new RelayCommand(() => { }, () => false); // TODO: Implement undo/redo
|
|
ValidateCommand = new RelayCommand(Validate);
|
|
TestConnectionCommand = new AsyncRelayCommand(TestConnectionAsync);
|
|
|
|
// SecureStore commands
|
|
NewStoreCommand = new AsyncRelayCommand(NewStoreAsync);
|
|
AddExistingStoreCommand = new AsyncRelayCommand(AddExistingStoreAsync);
|
|
SaveStoreCommand = new AsyncRelayCommand(SaveStoreAsync, CanSaveStore);
|
|
GenerateKeyFileCommand = new AsyncRelayCommand(GenerateKeyFileAsync);
|
|
AddSecretCommand = new AsyncRelayCommand(AddSecretAsync, CanAddSecret);
|
|
DeleteSecretCommand = new AsyncRelayCommand(DeleteSecretAsync, CanDeleteSecret);
|
|
|
|
// Pipeline commands
|
|
AddPipelineCommand = new AsyncRelayCommand(AddPipelineAsync, CanAddPipeline);
|
|
DeletePipelineCommand = new AsyncRelayCommand(DeletePipelineAsync, CanDeletePipeline);
|
|
|
|
// Validation commands
|
|
ValidateRuntimeConfigCommand = new AsyncRelayCommand(
|
|
ValidateRuntimeConfigAsync,
|
|
() => ConfigFolderPath != "No folder selected");
|
|
|
|
_ = InitializeAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Design-time constructor for XAML previewer.
|
|
/// </summary>
|
|
public MainWindowViewModel() : this(
|
|
new FileSystem(),
|
|
new ConfigFileService(new FileSystem()),
|
|
new ValidationService(),
|
|
new BackupService(new FileSystem()),
|
|
new AutoDiscoveryService(new FileSystem()),
|
|
null,
|
|
new SecureStoreManager(),
|
|
new NullClipboardService(),
|
|
new RuntimeConfigValidationService(new SecureStoreManager()),
|
|
new ConnectionTestService(),
|
|
null)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Null implementation of clipboard service for design-time.
|
|
/// </summary>
|
|
private class NullClipboardService : IClipboardService
|
|
{
|
|
/// <summary>
|
|
/// Sets text to clipboard (no-op for design-time).
|
|
/// </summary>
|
|
/// <param name="text">The text to set (ignored in this implementation).</param>
|
|
/// <returns>A completed task.</returns>
|
|
public Task SetTextAsync(string text) => Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes the view model by auto-discovering and loading configuration.
|
|
/// </summary>
|
|
private async Task InitializeAsync()
|
|
{
|
|
var folder = await _autoDiscoveryService.FindConfigFolderAsync();
|
|
if (folder != null)
|
|
{
|
|
await LoadConfigAsync(folder);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes the SecureStore automatically on config load.
|
|
/// Creates the store if it doesn't exist and AutoCreateStore is true.
|
|
/// Opens the store and ensures all required keys exist.
|
|
/// </summary>
|
|
private async Task InitializeSecureStoreAsync()
|
|
{
|
|
if (_appSettings?.SecureStore == null)
|
|
return;
|
|
|
|
if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
|
|
return;
|
|
|
|
var secureStoreConfig = _appSettings.SecureStore;
|
|
|
|
// Resolve paths relative to config folder
|
|
var storePath = Path.IsPathRooted(secureStoreConfig.StorePath)
|
|
? secureStoreConfig.StorePath
|
|
: Path.Combine(ConfigFolderPath, secureStoreConfig.StorePath);
|
|
|
|
var keyFilePath = Path.IsPathRooted(secureStoreConfig.KeyFilePath)
|
|
? secureStoreConfig.KeyFilePath
|
|
: Path.Combine(ConfigFolderPath, secureStoreConfig.KeyFilePath);
|
|
|
|
try
|
|
{
|
|
if (!File.Exists(storePath))
|
|
{
|
|
if (!secureStoreConfig.AutoCreateStore)
|
|
{
|
|
_logger?.LogWarning("SecureStore not found and AutoCreateStore is false");
|
|
return;
|
|
}
|
|
|
|
// Create new store with keyfile
|
|
_logger?.LogInformation("Creating SecureStore at {StorePath}", storePath);
|
|
_secureStoreManager.CreateStore(storePath, keyFilePath);
|
|
|
|
// Update appsettings.json with actual paths
|
|
secureStoreConfig.StorePath = GetRelativePath(ConfigFolderPath, storePath);
|
|
secureStoreConfig.KeyFilePath = GetRelativePath(ConfigFolderPath, keyFilePath);
|
|
await SaveAppSettingsAsync();
|
|
}
|
|
else
|
|
{
|
|
// Open existing store
|
|
if (!File.Exists(keyFilePath))
|
|
{
|
|
if (_dialogService != null)
|
|
{
|
|
await _dialogService.ShowMessageAsync(
|
|
"SecureStore Error",
|
|
$"Key file not found: {keyFilePath}\n\nThe SecureStore cannot be opened without its key file.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
_secureStoreManager.OpenStore(storePath, keyFilePath);
|
|
}
|
|
|
|
// Ensure all required entries exist (both RequiredKeys and connection strings)
|
|
var connectionStringNames = _appSettings?.ConnectionStrings?.Entries
|
|
.Select(e => e.Name)
|
|
.Where(n => !string.IsNullOrEmpty(n))
|
|
?? Enumerable.Empty<string>();
|
|
|
|
var requiredKeys = secureStoreConfig.RequiredKeys ?? new List<string>();
|
|
|
|
var addedKeys = _secureStoreManager.EnsureAllRequiredEntries(requiredKeys, connectionStringNames);
|
|
if (addedKeys.Count > 0)
|
|
{
|
|
_logger?.LogInformation("Added {Count} missing required SecureStore entries", addedKeys.Count);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Failed to initialize SecureStore");
|
|
if (_dialogService != null)
|
|
{
|
|
await _dialogService.ShowMessageAsync(
|
|
"SecureStore Error",
|
|
$"Failed to initialize SecureStore:\n\n{ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the relative path from a base path to a full path.
|
|
/// </summary>
|
|
private static string GetRelativePath(string basePath, string fullPath)
|
|
{
|
|
var baseUri = new Uri(basePath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar);
|
|
var fullUri = new Uri(fullPath);
|
|
return Uri.UnescapeDataString(baseUri.MakeRelativeUri(fullUri).ToString().Replace('/', Path.DirectorySeparatorChar));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the appsettings.json file.
|
|
/// </summary>
|
|
private async Task SaveAppSettingsAsync()
|
|
{
|
|
if (_appSettings == null) return;
|
|
|
|
var appSettingsPath = Path.Combine(ConfigFolderPath, "appsettings.json");
|
|
await _configFileService.SaveAppSettingsAsync(appSettingsPath, _appSettings);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens a file picker dialog to select a configuration file.
|
|
/// </summary>
|
|
private async Task OpenFolderAsync()
|
|
{
|
|
if (_dialogService == null)
|
|
{
|
|
_logger?.LogWarning("Dialog service is not available");
|
|
return;
|
|
}
|
|
|
|
var filePath = await _dialogService.ShowFilePickerAsync("Select Configuration File");
|
|
if (filePath != null)
|
|
{
|
|
var folder = Path.GetDirectoryName(filePath);
|
|
if (!string.IsNullOrEmpty(folder))
|
|
{
|
|
await LoadConfigAsync(folder);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads configuration files from the specified folder.
|
|
/// </summary>
|
|
/// <param name="folderPath">Path to the configuration folder.</param>
|
|
private async Task LoadConfigAsync(string folderPath)
|
|
{
|
|
try
|
|
{
|
|
ConfigFolderPath = folderPath;
|
|
|
|
var appSettingsPath = Path.Combine(folderPath, "appsettings.json");
|
|
_appSettings = await _configFileService.LoadAppSettingsAsync(appSettingsPath);
|
|
|
|
// Load pipelines from directory containing pipeline.*.json files
|
|
var pipelinesDirectory = Path.Combine(folderPath,
|
|
_appSettings?.Pipelines?.ConfigDirectory ?? "Pipelines");
|
|
|
|
if (Directory.Exists(pipelinesDirectory))
|
|
{
|
|
_pipelines = await _configFileService.LoadAllPipelinesAsync(pipelinesDirectory);
|
|
}
|
|
else
|
|
{
|
|
_pipelines = new Dictionary<string, EtlPipelineConfig>(StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// Initialize SecureStore (auto-create if needed, open, sync required keys)
|
|
await InitializeSecureStoreAsync();
|
|
|
|
BuildTreeNodes();
|
|
Validate();
|
|
|
|
_logger?.LogInformation("Loaded configuration from {Path}", folderPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Failed to load configuration from {Path}", folderPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the tree nodes representing the configuration structure.
|
|
/// </summary>
|
|
private void BuildTreeNodes()
|
|
{
|
|
TreeNodes.Clear();
|
|
|
|
// Settings folder
|
|
var settingsFolder = new TreeNodeViewModel("Settings", "⚙️", TreeNodeType.Folder) { IsExpanded = true };
|
|
settingsFolder.Children.Add(new TreeNodeViewModel("DataSync", "🔄", TreeNodeType.SettingsSection) { SectionKey = "DataSync" });
|
|
settingsFolder.Children.Add(new TreeNodeViewModel("DataAccess", "🗄️", TreeNodeType.SettingsSection) { SectionKey = "DataAccess" });
|
|
settingsFolder.Children.Add(new TreeNodeViewModel("Auth", "🔐", TreeNodeType.SettingsSection) { SectionKey = "Auth" });
|
|
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
|
|
var pipelinesFolder = new TreeNodeViewModel("Pipelines", "⚡", TreeNodeType.Folder) { IsExpanded = true };
|
|
if (_pipelines != null)
|
|
{
|
|
foreach (var name in _pipelines.Keys.OrderBy(k => k))
|
|
{
|
|
pipelinesFolder.Children.Add(new TreeNodeViewModel(name, "📦", TreeNodeType.Pipeline) { SectionKey = name });
|
|
}
|
|
}
|
|
TreeNodes.Add(pipelinesFolder);
|
|
|
|
// Secure Store node (single store, secrets as direct children)
|
|
var secureStoreNode = CreateSecureStoreNode();
|
|
if (secureStoreNode != null)
|
|
{
|
|
TreeNodes.Add(secureStoreNode);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates the SecureStore tree node with secrets as direct children.
|
|
/// </summary>
|
|
private TreeNodeViewModel? CreateSecureStoreNode()
|
|
{
|
|
if (_appSettings?.SecureStore == null)
|
|
return null;
|
|
|
|
if (string.IsNullOrEmpty(ConfigFolderPath) || ConfigFolderPath == "No folder selected")
|
|
return null;
|
|
|
|
var storePath = Path.IsPathRooted(_appSettings.SecureStore.StorePath)
|
|
? _appSettings.SecureStore.StorePath
|
|
: Path.Combine(ConfigFolderPath, _appSettings.SecureStore.StorePath);
|
|
|
|
var keyFilePath = Path.IsPathRooted(_appSettings.SecureStore.KeyFilePath)
|
|
? _appSettings.SecureStore.KeyFilePath
|
|
: Path.Combine(ConfigFolderPath, _appSettings.SecureStore.KeyFilePath);
|
|
|
|
var storeNode = new TreeNodeViewModel("Secure Store", "🔑", TreeNodeType.SecureStore)
|
|
{
|
|
StorePath = storePath,
|
|
KeyFilePath = keyFilePath,
|
|
SectionKey = storePath,
|
|
IsExpanded = true
|
|
};
|
|
|
|
// Add secrets as direct children if store is open
|
|
if (_secureStoreManager.IsStoreOpen)
|
|
{
|
|
foreach (var key in _secureStoreManager.GetKeys().OrderBy(k => k))
|
|
{
|
|
var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret)
|
|
{
|
|
SecretKey = key
|
|
};
|
|
storeNode.Children.Add(secretNode);
|
|
}
|
|
}
|
|
|
|
return storeNode;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when the selected tree node changes; updates the form view model.
|
|
/// </summary>
|
|
private void OnSelectedNodeChanged()
|
|
{
|
|
RaisePipelineCommandsCanExecuteChanged();
|
|
|
|
if (_selectedNode == null)
|
|
{
|
|
SelectedFormViewModel = null;
|
|
return;
|
|
}
|
|
|
|
// Handle SecureStore-related node types first
|
|
switch (_selectedNode.NodeType)
|
|
{
|
|
case TreeNodeType.SecureStore:
|
|
SelectedFormViewModel = CreateSecureStoreInfoFormViewModel();
|
|
RaiseSecureStoreCommandsCanExecuteChanged();
|
|
return;
|
|
|
|
case TreeNodeType.Secret:
|
|
SelectedFormViewModel = CreateSecretFormViewModel(_selectedNode);
|
|
RaiseSecureStoreCommandsCanExecuteChanged();
|
|
return;
|
|
}
|
|
|
|
// Handle standard configuration sections
|
|
if (_appSettings == null)
|
|
{
|
|
SelectedFormViewModel = null;
|
|
return;
|
|
}
|
|
|
|
SelectedFormViewModel = _selectedNode.SectionKey switch
|
|
{
|
|
"DataSync" => new DataSyncFormViewModel(_appSettings.DataSync, MarkAsChanged),
|
|
"DataAccess" => new DataAccessFormViewModel(_appSettings.DataAccess, MarkAsChanged),
|
|
"Auth" => new AuthFormViewModel(_appSettings.Auth, MarkAsChanged),
|
|
"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,
|
|
_secureStoreManager,
|
|
MarkAsChanged,
|
|
_dialogService,
|
|
_connectionTestService),
|
|
_ when _selectedNode.NodeType == TreeNodeType.Pipeline && _pipelines != null && _dialogService != null
|
|
=> _pipelines.TryGetValue(_selectedNode.SectionKey!, out var pipeline)
|
|
? new PipelineEditorViewModel(_selectedNode.SectionKey!, pipeline, GetAvailableConnections(), _dialogService, MarkAsChanged)
|
|
: null,
|
|
_ => null
|
|
};
|
|
RaiseSecureStoreCommandsCanExecuteChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a form view model for the SecureStore info panel.
|
|
/// </summary>
|
|
private SecureStoreInfoFormViewModel CreateSecureStoreInfoFormViewModel()
|
|
{
|
|
var storePath = _appSettings?.SecureStore?.StorePath ?? "Unknown";
|
|
var keyFilePath = _appSettings?.SecureStore?.KeyFilePath ?? "Unknown";
|
|
var secretCount = _secureStoreManager.IsStoreOpen ? _secureStoreManager.GetKeys().Count : 0;
|
|
|
|
return new SecureStoreInfoFormViewModel(storePath, keyFilePath, secretCount);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a form view model for a secret.
|
|
/// </summary>
|
|
private SecretFormViewModel? CreateSecretFormViewModel(TreeNodeViewModel secretNode)
|
|
{
|
|
if (string.IsNullOrEmpty(secretNode.SecretKey))
|
|
return null;
|
|
|
|
try
|
|
{
|
|
var value = _secureStoreManager.GetSecret(secretNode.SecretKey);
|
|
return new SecretFormViewModel(
|
|
secretNode.SecretKey,
|
|
value,
|
|
_clipboardService,
|
|
newValue => OnSecretValueChanged(secretNode.SecretKey, newValue),
|
|
() => _ = DeleteSecretAsync());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Failed to get secret value for key {Key}", secretNode.SecretKey);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when a secret value changes in the form.
|
|
/// </summary>
|
|
private void OnSecretValueChanged(string key, string newValue)
|
|
{
|
|
try
|
|
{
|
|
_secureStoreManager.SetSecret(key, newValue);
|
|
_logger?.LogDebug("Secret {Key} value updated", key);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Failed to update secret {Key}", key);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raises CanExecuteChanged for all SecureStore commands.
|
|
/// </summary>
|
|
private void RaiseSecureStoreCommandsCanExecuteChanged()
|
|
{
|
|
(SaveStoreCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
(AddSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
(DeleteSecretCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks the configuration as having unsaved changes.
|
|
/// </summary>
|
|
private void MarkAsChanged()
|
|
{
|
|
HasUnsavedChanges = true;
|
|
if (_selectedNode != null)
|
|
_selectedNode.IsModified = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Loads configuration for testing purposes.
|
|
/// </summary>
|
|
/// <param name="appSettings">The application settings configuration model.</param>
|
|
/// <param name="pipelines">The pipelines dictionary.</param>
|
|
public void LoadConfigForTesting(ConfigModel? appSettings, Dictionary<string, EtlPipelineConfig>? pipelines)
|
|
{
|
|
_appSettings = appSettings;
|
|
_pipelines = pipelines;
|
|
BuildTreeNodes();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves all configuration changes to disk.
|
|
/// </summary>
|
|
private async Task SaveAsync()
|
|
{
|
|
if (_appSettings == null) return;
|
|
|
|
try
|
|
{
|
|
var appSettingsPath = Path.Combine(ConfigFolderPath, "appsettings.json");
|
|
|
|
// Create backup before saving
|
|
if (File.Exists(appSettingsPath))
|
|
{
|
|
await _backupService.CreateBackupAsync(appSettingsPath);
|
|
}
|
|
|
|
// Save appsettings
|
|
await _configFileService.SaveAppSettingsAsync(appSettingsPath, _appSettings);
|
|
|
|
// Save each pipeline to its own file
|
|
if (_pipelines != null)
|
|
{
|
|
var pipelinesDirectory = Path.Combine(ConfigFolderPath,
|
|
_appSettings?.Pipelines?.ConfigDirectory ?? "Pipelines");
|
|
|
|
Directory.CreateDirectory(pipelinesDirectory);
|
|
|
|
foreach (var (name, pipeline) in _pipelines)
|
|
{
|
|
var filePath = Path.Combine(pipelinesDirectory, $"pipeline.{name}.json");
|
|
await _configFileService.SavePipelineAsync(filePath, pipeline);
|
|
}
|
|
}
|
|
|
|
HasUnsavedChanges = false;
|
|
_logger?.LogInformation("Configuration saved");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Failed to save configuration");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the current configuration and updates the status bar.
|
|
/// </summary>
|
|
private void Validate()
|
|
{
|
|
var errors = 0;
|
|
var warnings = 0;
|
|
|
|
if (_appSettings != null)
|
|
{
|
|
var result = _validationService.ValidateAppSettings(_appSettings);
|
|
errors += result.Errors.Count;
|
|
warnings += result.Warnings.Count;
|
|
}
|
|
|
|
if (_pipelines != null)
|
|
{
|
|
var result = _validationService.ValidatePipelines(_pipelines);
|
|
errors += result.Errors.Count;
|
|
warnings += result.Warnings.Count;
|
|
}
|
|
|
|
if (errors > 0)
|
|
{
|
|
ValidationStatus = $"Errors: {errors}, Warnings: {warnings}";
|
|
ValidationStatusColor = new SolidColorBrush(Color.Parse("#FF6B6B"));
|
|
}
|
|
else if (warnings > 0)
|
|
{
|
|
ValidationStatus = $"Warnings: {warnings}";
|
|
ValidationStatusColor = new SolidColorBrush(Color.Parse("#FFB84D"));
|
|
}
|
|
else
|
|
{
|
|
ValidationStatus = "Valid";
|
|
ValidationStatusColor = new SolidColorBrush(Color.Parse("#3DD68C"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates runtime configuration using Infrastructure validators (SecureStore, Connection Strings, LDAP).
|
|
/// </summary>
|
|
private async Task ValidateRuntimeConfigAsync()
|
|
{
|
|
if (_dialogService == null || ConfigFolderPath == "No folder selected")
|
|
return;
|
|
|
|
var results = _runtimeValidationService.ValidateRuntimeConfig(ConfigFolderPath);
|
|
|
|
// Convert to ValidationResult for dialog
|
|
var appSettingsResult = new ValidationResult();
|
|
var pipelinesResult = new ValidationResult(); // Will remain empty for runtime validation
|
|
|
|
foreach (var result in results)
|
|
{
|
|
foreach (var error in result.Errors)
|
|
appSettingsResult.AddError($"[{result.ValidatorName}] {error}");
|
|
foreach (var warning in result.Warnings)
|
|
appSettingsResult.AddWarning($"[{result.ValidatorName}] {warning}");
|
|
}
|
|
|
|
await _dialogService.ShowValidationResultsAsync(appSettingsResult, pipelinesResult);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Tests database connections defined in the configuration.
|
|
/// </summary>
|
|
private async Task TestConnectionAsync()
|
|
{
|
|
// TODO: Implement connection testing
|
|
_logger?.LogInformation("Test connection requested");
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the list of available connection names from the configuration.
|
|
/// </summary>
|
|
private IReadOnlyList<string> GetAvailableConnections()
|
|
{
|
|
// Return well-known connection names for the JDE Scoping Tool
|
|
// These match the connection string names in appsettings.json
|
|
return new List<string>
|
|
{
|
|
"jde",
|
|
"cms",
|
|
"giw",
|
|
"lotfinder"
|
|
}.AsReadOnly();
|
|
}
|
|
|
|
#region Pipeline Commands
|
|
|
|
/// <summary>
|
|
/// Determines whether a new pipeline can be added.
|
|
/// </summary>
|
|
private bool CanAddPipeline()
|
|
{
|
|
return _pipelines != null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether the selected pipeline can be deleted.
|
|
/// </summary>
|
|
private bool CanDeletePipeline()
|
|
{
|
|
return _selectedNode?.NodeType == TreeNodeType.Pipeline
|
|
&& _pipelines != null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a new pipeline to the configuration.
|
|
/// </summary>
|
|
private async Task AddPipelineAsync()
|
|
{
|
|
if (_pipelines == null || _dialogService == null)
|
|
return;
|
|
|
|
// Get pipeline name from user
|
|
var name = await _dialogService.ShowInputDialogAsync(
|
|
"New Pipeline",
|
|
"Enter pipeline name:");
|
|
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return;
|
|
|
|
// Check for duplicate
|
|
if (_pipelines.ContainsKey(name))
|
|
{
|
|
await _dialogService.ShowMessageAsync("Error",
|
|
$"Pipeline '{name}' already exists.");
|
|
return;
|
|
}
|
|
|
|
// Create default pipeline using EtlPipelineConfig
|
|
var pipeline = new EtlPipelineConfig
|
|
{
|
|
Name = name,
|
|
IsEnabled = true,
|
|
Source = new SourceElement { Connection = "lotfinder", Query = "" },
|
|
Destination = new DestinationElement { Table = name }
|
|
};
|
|
|
|
_pipelines[name] = pipeline;
|
|
|
|
// Add tree node
|
|
var pipelinesFolder = TreeNodes.FirstOrDefault(n =>
|
|
n.Name == "Pipelines" && n.NodeType == TreeNodeType.Folder);
|
|
if (pipelinesFolder != null)
|
|
{
|
|
var node = new TreeNodeViewModel(name, "📦", TreeNodeType.Pipeline)
|
|
{ SectionKey = name };
|
|
pipelinesFolder.Children.Add(node);
|
|
SelectedNode = node;
|
|
}
|
|
|
|
MarkAsChanged();
|
|
RaisePipelineCommandsCanExecuteChanged();
|
|
|
|
_logger?.LogInformation("Pipeline created: {Name}", name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes the selected pipeline from the configuration.
|
|
/// </summary>
|
|
private async Task DeletePipelineAsync()
|
|
{
|
|
if (_selectedNode?.NodeType != TreeNodeType.Pipeline ||
|
|
_pipelines == null ||
|
|
_dialogService == null ||
|
|
_appSettings == null)
|
|
return;
|
|
|
|
var name = _selectedNode.SectionKey!;
|
|
|
|
var confirmed = await _dialogService.ShowConfirmationAsync(
|
|
"Delete Pipeline",
|
|
$"Are you sure you want to delete pipeline '{name}'?");
|
|
|
|
if (!confirmed)
|
|
return;
|
|
|
|
// Remove from model
|
|
_pipelines.Remove(name);
|
|
|
|
// Delete the pipeline file
|
|
var pipelinesDirectory = Path.Combine(ConfigFolderPath,
|
|
_appSettings.Pipelines?.ConfigDirectory ?? "Pipelines");
|
|
var filePath = Path.Combine(pipelinesDirectory, $"pipeline.{name}.json");
|
|
if (File.Exists(filePath))
|
|
{
|
|
await _configFileService.DeletePipelineFileAsync(filePath);
|
|
}
|
|
|
|
// Remove tree node
|
|
var pipelinesFolder = TreeNodes.FirstOrDefault(n =>
|
|
n.Name == "Pipelines" && n.NodeType == TreeNodeType.Folder);
|
|
pipelinesFolder?.Children.Remove(_selectedNode);
|
|
|
|
// Clear selection
|
|
SelectedNode = pipelinesFolder;
|
|
SelectedFormViewModel = null;
|
|
|
|
MarkAsChanged();
|
|
RaisePipelineCommandsCanExecuteChanged();
|
|
|
|
_logger?.LogInformation("Pipeline deleted: {Name}", name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raises CanExecuteChanged for all Pipeline commands.
|
|
/// </summary>
|
|
private void RaisePipelineCommandsCanExecuteChanged()
|
|
{
|
|
(AddPipelineCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
(DeletePipelineCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged();
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region SecureStore Commands
|
|
|
|
/// <summary>
|
|
/// Determines whether the current store can be saved.
|
|
/// </summary>
|
|
private bool CanSaveStore()
|
|
{
|
|
return _secureStoreManager.IsStoreOpen
|
|
&& _secureStoreManager.HasUnsavedChanges;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether a secret can be added.
|
|
/// </summary>
|
|
private bool CanAddSecret()
|
|
{
|
|
return _secureStoreManager.IsStoreOpen;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether a secret can be deleted.
|
|
/// </summary>
|
|
private bool CanDeleteSecret()
|
|
{
|
|
return _selectedNode != null
|
|
&& _selectedNode.NodeType == TreeNodeType.Secret
|
|
&& _secureStoreManager.IsStoreOpen;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new secure store.
|
|
/// </summary>
|
|
private async Task NewStoreAsync()
|
|
{
|
|
if (_dialogService == null)
|
|
{
|
|
_logger?.LogWarning("Dialog service is not available");
|
|
return;
|
|
}
|
|
|
|
// Note: In a full implementation, this would show the NewStoreDialog
|
|
// For now, we use a simple message dialog to inform the user
|
|
// A proper implementation would wire up the NewStoreDialogViewModel events
|
|
await _dialogService.ShowMessageAsync(
|
|
"New Store",
|
|
"New store creation dialog not yet implemented. Use Add Existing Store instead.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds an existing secure store to the tree.
|
|
/// </summary>
|
|
private async Task AddExistingStoreAsync()
|
|
{
|
|
if (_dialogService == null)
|
|
{
|
|
_logger?.LogWarning("Dialog service is not available");
|
|
return;
|
|
}
|
|
|
|
// Note: In a full implementation, this would show a file picker
|
|
await _dialogService.ShowMessageAsync(
|
|
"Add Existing Store",
|
|
"File picker for existing stores not yet implemented.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Saves the currently open secure store.
|
|
/// </summary>
|
|
private async Task SaveStoreAsync()
|
|
{
|
|
if (!_secureStoreManager.IsStoreOpen)
|
|
return;
|
|
|
|
try
|
|
{
|
|
_secureStoreManager.Save();
|
|
|
|
// Refresh the form to update the HasUnsavedChanges state
|
|
OnSelectedNodeChanged();
|
|
RaiseSecureStoreCommandsCanExecuteChanged();
|
|
|
|
_logger?.LogInformation("Store saved: {StorePath}", _secureStoreManager.CurrentStorePath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Failed to save store");
|
|
if (_dialogService != null)
|
|
{
|
|
await _dialogService.ShowMessageAsync(
|
|
SecureStoreStrings.ErrorTitle,
|
|
string.Format(SecureStoreStrings.FailedToSaveStoreFormat, ex.Message));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a new key file.
|
|
/// </summary>
|
|
private async Task GenerateKeyFileAsync()
|
|
{
|
|
if (_dialogService == null)
|
|
{
|
|
_logger?.LogWarning("Dialog service is not available");
|
|
return;
|
|
}
|
|
|
|
// Note: In a full implementation, this would show a save file dialog
|
|
await _dialogService.ShowMessageAsync(
|
|
SecureStoreStrings.GenerateKeyFileTitle,
|
|
"Key file generation dialog not yet implemented.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a new secret to the current store.
|
|
/// </summary>
|
|
private async Task AddSecretAsync()
|
|
{
|
|
if (!_secureStoreManager.IsStoreOpen)
|
|
return;
|
|
|
|
if (_dialogService == null)
|
|
{
|
|
_logger?.LogWarning("Dialog service is not available");
|
|
return;
|
|
}
|
|
|
|
// Note: In a full implementation, this would show the SecretEditDialog
|
|
// For demonstration, we'll show a message
|
|
await _dialogService.ShowMessageAsync(
|
|
"Add Secret",
|
|
"Secret edit dialog not yet implemented.\n\nTo add secrets, the SecretEditDialog must be wired up.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes the currently selected secret.
|
|
/// </summary>
|
|
private async Task DeleteSecretAsync()
|
|
{
|
|
if (_selectedNode == null ||
|
|
_selectedNode.NodeType != TreeNodeType.Secret ||
|
|
string.IsNullOrEmpty(_selectedNode.SecretKey))
|
|
return;
|
|
|
|
if (_dialogService == null)
|
|
{
|
|
_logger?.LogWarning("Dialog service is not available");
|
|
return;
|
|
}
|
|
|
|
var confirmed = await _dialogService.ShowConfirmationAsync(
|
|
SecureStoreStrings.ConfirmDeleteTitle,
|
|
string.Format(SecureStoreStrings.ConfirmDeleteFormat, _selectedNode.SecretKey));
|
|
|
|
if (!confirmed)
|
|
return;
|
|
|
|
try
|
|
{
|
|
_secureStoreManager.RemoveSecret(_selectedNode.SecretKey);
|
|
|
|
// Find the Secure Store node and remove the secret from its children
|
|
var secureStoreNode = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStore);
|
|
if (secureStoreNode != null)
|
|
{
|
|
secureStoreNode.Children.Remove(_selectedNode);
|
|
|
|
// Select the parent store node
|
|
SelectedNode = secureStoreNode;
|
|
}
|
|
|
|
_logger?.LogInformation("Secret deleted: {Key}", _selectedNode.SecretKey);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Failed to delete secret: {Key}", _selectedNode.SecretKey);
|
|
await _dialogService.ShowMessageAsync(
|
|
SecureStoreStrings.ErrorTitle,
|
|
string.Format(SecureStoreStrings.FailedToDeleteSecretFormat, ex.Message));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refreshes the secret children of a store node.
|
|
/// </summary>
|
|
private void RefreshStoreChildren(TreeNodeViewModel storeNode)
|
|
{
|
|
storeNode.Children.Clear();
|
|
|
|
if (!_secureStoreManager.IsStoreOpen)
|
|
return;
|
|
|
|
try
|
|
{
|
|
var keys = _secureStoreManager.GetKeys();
|
|
foreach (var key in keys.OrderBy(k => k))
|
|
{
|
|
var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret)
|
|
{
|
|
SecretKey = key,
|
|
SectionKey = key
|
|
};
|
|
storeNode.Children.Add(secretNode);
|
|
}
|
|
|
|
_logger?.LogDebug("Refreshed {Count} secrets for store", keys.Count);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger?.LogError(ex, "Failed to refresh store children");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|