Files
jdescopingtool/NEW/src/Utils/JdeScoping.ConfigManager/ViewModels/MainWindowViewModel.cs
T
Joseph Doherty 7c4781dfe3 refactor(configmanager): split into Core, CLI, and UI projects
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.
2026-01-28 10:01:48 -05:00

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
}