feat: add startup config validation and document ConfigManager pipeline editor

Add ConfigurationValidationRunner with IConfigurationValidator interface for
validating required settings at startup. Includes SecureStore and LDAP validators.
Expand ConfigManager with pipeline editing UI, dialogs, and step editors.
Update documentation with config validation guidance.
This commit is contained in:
Joseph Doherty
2026-01-21 17:47:15 -05:00
parent ceb63bfefb
commit e5fe2f06e9
88 changed files with 4995 additions and 201 deletions
@@ -183,6 +183,16 @@ public class MainWindowViewModel : ViewModelBase
/// </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>
/// Initializes a new instance of the <see cref="MainWindowViewModel"/> class.
/// </summary>
@@ -235,6 +245,10 @@ public class MainWindowViewModel : ViewModelBase
AddSecretCommand = new AsyncRelayCommand(AddSecretAsync, CanAddSecret);
DeleteSecretCommand = new AsyncRelayCommand(DeleteSecretAsync, CanDeleteSecret);
// Pipeline commands
AddPipelineCommand = new AsyncRelayCommand(AddPipelineAsync, CanAddPipeline);
DeletePipelineCommand = new AsyncRelayCommand(DeletePipelineAsync, CanDeletePipeline);
_ = InitializeAsync();
}
@@ -271,9 +285,67 @@ public class MainWindowViewModel : ViewModelBase
if (folder != null)
{
await LoadConfigAsync(folder);
await EnsureDefaultSecureStoreAsync(folder);
}
}
/// <summary>
/// Ensures a default secure store exists and is loaded.
/// Creates one if it doesn't exist.
/// </summary>
private async Task EnsureDefaultSecureStoreAsync(string configFolder)
{
var defaultStorePath = Path.Combine(configFolder, "default.secrets.json");
var defaultKeyPath = Path.Combine(configFolder, "default.secrets.key");
try
{
// Create default store if it doesn't exist
if (!File.Exists(defaultStorePath))
{
_logger?.LogInformation("Creating default secure store at {Path}", defaultStorePath);
_secureStoreManager.CreateStore(defaultStorePath, defaultKeyPath);
// Add some example secrets
_secureStoreManager.SetSecret("jde-password", "");
_secureStoreManager.SetSecret("cms-password", "");
_secureStoreManager.SetSecret("lotfinder-password", "");
_secureStoreManager.Save();
_secureStoreManager.CloseStore();
// Rebuild tree to show the new store
BuildTreeNodes();
}
// Auto-unlock the default store if key file exists
if (File.Exists(defaultStorePath) && File.Exists(defaultKeyPath))
{
// Find the default store node in the tree
var secureStoresFolder = TreeNodes.FirstOrDefault(n => n.NodeType == TreeNodeType.SecureStoresFolder);
var defaultStoreNode = secureStoresFolder?.Children.FirstOrDefault(n =>
n.StorePath != null && n.StorePath.EndsWith("default.secrets.json"));
if (defaultStoreNode != null)
{
_secureStoreManager.OpenStore(defaultStorePath, defaultKeyPath);
defaultStoreNode.IsUnlocked = true;
_openStores[defaultStorePath] = defaultStoreNode;
RefreshStoreChildren(defaultStoreNode);
defaultStoreNode.IsExpanded = true;
_logger?.LogInformation("Auto-unlocked default secure store");
}
}
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Failed to initialize default secure store");
}
await Task.CompletedTask;
}
/// <summary>
/// Opens a folder picker dialog to select a configuration folder.
/// </summary>
@@ -331,28 +403,28 @@ public class MainWindowViewModel : ViewModelBase
TreeNodes.Clear();
// Settings folder
var settingsFolder = new TreeNodeViewModel("Settings", "gear", TreeNodeType.Folder) { IsExpanded = true };
settingsFolder.Children.Add(new TreeNodeViewModel("DataSync", "sync", TreeNodeType.SettingsSection) { SectionKey = "DataSync" });
settingsFolder.Children.Add(new TreeNodeViewModel("DataAccess", "database", TreeNodeType.SettingsSection) { SectionKey = "DataAccess" });
settingsFolder.Children.Add(new TreeNodeViewModel("Auth", "lock", TreeNodeType.SettingsSection) { SectionKey = "Auth" });
settingsFolder.Children.Add(new TreeNodeViewModel("Ldap", "users", TreeNodeType.SettingsSection) { SectionKey = "Ldap" });
settingsFolder.Children.Add(new TreeNodeViewModel("Search", "search", TreeNodeType.SettingsSection) { SectionKey = "Search" });
settingsFolder.Children.Add(new TreeNodeViewModel("ExcelExport", "file-spreadsheet", TreeNodeType.SettingsSection) { SectionKey = "ExcelExport" });
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" });
TreeNodes.Add(settingsFolder);
// Pipelines folder
var pipelinesFolder = new TreeNodeViewModel("Pipelines", "workflow", TreeNodeType.Folder) { IsExpanded = true };
var pipelinesFolder = new TreeNodeViewModel("Pipelines", "", TreeNodeType.Folder) { IsExpanded = true };
if (_pipelines != null)
{
foreach (var (name, _) in _pipelines.Pipelines)
{
pipelinesFolder.Children.Add(new TreeNodeViewModel(name, "zap", TreeNodeType.Pipeline) { SectionKey = name });
pipelinesFolder.Children.Add(new TreeNodeViewModel(name, "📦", TreeNodeType.Pipeline) { SectionKey = name });
}
}
TreeNodes.Add(pipelinesFolder);
// Secure Stores folder
var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "key", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
var secureStoresFolder = new TreeNodeViewModel("Secure Stores", "🔑", TreeNodeType.SecureStoresFolder) { IsExpanded = true };
DiscoverSecureStores(secureStoresFolder);
TreeNodes.Add(secureStoresFolder);
}
@@ -378,7 +450,7 @@ public class MainWindowViewModel : ViewModelBase
if (storeName.EndsWith(".secrets"))
storeName = storeName[..^8]; // Remove ".secrets" suffix for display
var storeNode = new TreeNodeViewModel(storeName, "lock", TreeNodeType.SecureStore)
var storeNode = new TreeNodeViewModel(storeName, "🔒", TreeNodeType.SecureStore)
{
StorePath = filePath,
SectionKey = filePath,
@@ -400,6 +472,8 @@ public class MainWindowViewModel : ViewModelBase
/// </summary>
private void OnSelectedNodeChanged()
{
RaisePipelineCommandsCanExecuteChanged();
if (_selectedNode == null)
{
SelectedFormViewModel = null;
@@ -455,7 +529,7 @@ public class MainWindowViewModel : ViewModelBase
"ExcelExport" => new ExcelExportFormViewModel(_appSettings.ExcelExport, MarkAsChanged),
_ when _selectedNode.NodeType == TreeNodeType.Pipeline && _pipelines != null
=> _pipelines.Pipelines.TryGetValue(_selectedNode.SectionKey!, out var pipeline)
? new PipelineFormViewModel(_selectedNode.SectionKey!, pipeline, MarkAsChanged)
? new PipelineEditorViewModel(_selectedNode.SectionKey!, pipeline, GetAvailableConnections(), MarkAsChanged)
: null,
_ => null
};
@@ -682,6 +756,140 @@ public class MainWindowViewModel : ViewModelBase
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.Pipelines.ContainsKey(name))
{
await _dialogService.ShowMessageAsync("Error",
$"Pipeline '{name}' already exists.");
return;
}
// Create default pipeline model
var pipeline = new PipelineModel
{
Source = new PipelineSource { Connection = "lotfinder", Query = "" },
Destination = new PipelineDestination { Table = name },
Schedules = new PipelineSchedules()
};
_pipelines.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)
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.Pipelines.Remove(name);
// 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>
@@ -1014,7 +1222,7 @@ public class MainWindowViewModel : ViewModelBase
var keys = _secureStoreManager.GetKeys();
foreach (var key in keys.OrderBy(k => k))
{
var secretNode = new TreeNodeViewModel(key, "key", TreeNodeType.Secret)
var secretNode = new TreeNodeViewModel(key, "🔐", TreeNodeType.Secret)
{
SecretKey = key,
SectionKey = key