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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user