# ETL Pipeline Viewer Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Implement the ETL Pipeline Viewer Blazor component with API backend. **Architecture:** API-first approach - build DTOs and API endpoints first, then client components. **Tech Stack:** .NET 10, ASP.NET Core API, Blazor WebAssembly, Radzen, Dapper --- ## Task 1: Create Pipeline DTOs in Core **Files:** - Create: `src/JdeScoping.Core/ApiContracts/Pipelines/PipelineListResponse.cs` - Create: `src/JdeScoping.Core/ApiContracts/Pipelines/PipelineConfigDto.cs` - Create: `src/JdeScoping.Core/ApiContracts/Pipelines/PipelineStatusDto.cs` - Create: `src/JdeScoping.Core/ApiContracts/Pipelines/PipelineExecutionDto.cs` **Step 1: Create the Pipelines directory** ```bash mkdir -p src/JdeScoping.Core/ApiContracts/Pipelines ``` **Step 2: Create PipelineListResponse.cs** ```csharp namespace JdeScoping.Core.ApiContracts.Pipelines; /// /// Response containing list of available pipeline names. /// public record PipelineListResponse(List PipelineNames); ``` **Step 3: Create PipelineConfigDto.cs** ```csharp namespace JdeScoping.Core.ApiContracts.Pipelines; /// /// Pipeline configuration DTO for display. /// public record PipelineConfigDto( string Name, PipelineSourceDto Source, PipelineDestinationDto Destination, PipelineSchedulesDto Schedules, int PreScriptCount, int PostScriptCount, List? PreScripts, List? PostScripts); public record PipelineSourceDto( string Connection, string? QueryPreview, string? MassQueryPreview, string? Query, string? MassQuery, List Parameters); public record PipelineParameterDto( string Name, string? Format, string Source); public record PipelineDestinationDto( string Table, string OperationType, List? MatchColumns, List? ExcludeFromUpdate); public record PipelineSchedulesDto( PipelineScheduleDto Mass, PipelineScheduleDto Daily, PipelineScheduleDto Hourly); public record PipelineScheduleDto( bool Enabled, int IntervalMinutes, bool PrePurge, bool ReIndex, bool IntervalIsOverride, bool PrePurgeIsOverride, bool ReIndexIsOverride); ``` **Step 4: Create PipelineStatusDto.cs** ```csharp namespace JdeScoping.Core.ApiContracts.Pipelines; using JdeScoping.Core.Models.Enums; /// /// Pipeline schedule status for each update type. /// public record PipelineStatusResponse(List Statuses); public record PipelineScheduleStatusDto( UpdateTypes ScheduleType, DateTime? LastRun, bool LastRunWasSuccessful, DateTime? LastSuccessfulRun, DateTime? NextRequiredRun, bool IsOverdue, int IntervalMinutes); ``` **Step 5: Create PipelineExecutionDto.cs** ```csharp namespace JdeScoping.Core.ApiContracts.Pipelines; using JdeScoping.Core.Models.Enums; /// /// Pipeline execution history. /// public record PipelineExecutionsResponse(List Executions); public record PipelineExecutionDto( UpdateTypes ScheduleType, DateTime StartTime, DateTime? EndTime, TimeSpan? Duration, long RecordCount, bool WasSuccessful); ``` **Step 6: Verify build** ```bash dotnet build src/JdeScoping.Core ``` **Step 7: Commit** ```bash git add src/JdeScoping.Core/ApiContracts/Pipelines/ git commit -m "feat(core): add pipeline viewer DTOs" ``` --- ## Task 2: Add Pipeline Routes to ApiRoutes **Files:** - Modify: `src/JdeScoping.Core/ApiContracts/ApiRoutes.cs` **Step 1: Add Pipelines routes class after FileIO class** Add at the end of `ApiRoutes.cs`, before the closing brace: ```csharp /// /// Routes for pipeline configuration API endpoints. /// public static class Pipelines { /// Base route for pipeline endpoints. public const string Base = "api/pipelines"; /// Route template for getting a pipeline by name. public const string ByName = "{name}"; /// Route template for getting pipeline status. public const string Status = "{name}/status"; /// Route template for getting pipeline executions. public const string Executions = "{name}/executions"; /// Builds the route to get a specific pipeline config. public static string GetByName(string name) => $"api/pipelines/{Uri.EscapeDataString(name)}"; /// Builds the route to get pipeline status. public static string GetStatus(string name) => $"api/pipelines/{Uri.EscapeDataString(name)}/status"; /// Builds the route to get pipeline executions. public static string GetExecutions(string name, int count = 10) => $"api/pipelines/{Uri.EscapeDataString(name)}/executions?count={count}"; } ``` **Step 2: Verify build** ```bash dotnet build src/JdeScoping.Core ``` **Step 3: Commit** ```bash git add src/JdeScoping.Core/ApiContracts/ApiRoutes.cs git commit -m "feat(core): add pipeline API routes" ``` --- ## Task 3: Add Repository Methods to IDataUpdateRepository **Files:** - Modify: `src/JdeScoping.DataSync/Contracts/IDataUpdateRepository.cs` **Step 1: Add new method signatures** Add these methods to the interface: ```csharp /// /// Gets the last N execution records for a specific table. /// /// The table name to filter by. /// Optional update type filter. If null, returns all types. /// Maximum records to return (total, not per type). /// Cancellation token. /// List of DataUpdate records ordered by StartDt descending. Task> GetRecentUpdatesAsync( string tableName, UpdateTypes? updateType = null, int count = 30, CancellationToken cancellationToken = default); /// /// Gets the last run (successful or not) for each update type for a table. /// /// The table name. /// Cancellation token. /// Dictionary keyed by UpdateType. Task> GetLastRunsAsync( string tableName, CancellationToken cancellationToken = default); ``` **Step 2: Verify build** ```bash dotnet build src/JdeScoping.DataSync ``` Expected: Build fails (not implemented yet) **Step 3: Commit interface changes** ```bash git add src/JdeScoping.DataSync/Contracts/IDataUpdateRepository.cs git commit -m "feat(datasync): add repository methods for pipeline viewer" ``` --- ## Task 4: Implement Repository Methods **Files:** - Modify: `src/JdeScoping.DataSync/Services/DataUpdateRepository.cs` **Step 1: Implement GetRecentUpdatesAsync** Add this method to `DataUpdateRepository`: ```csharp /// public async Task> GetRecentUpdatesAsync( string tableName, UpdateTypes? updateType = null, int count = 30, CancellationToken cancellationToken = default) { var sql = updateType.HasValue ? @"SELECT TOP (@count) du.Id, du.SourceSystem, du.SourceData, du.TableName, du.StartDt, du.EndDt, du.UpdateType, du.WasSuccessful, du.NumberRecords FROM dbo.DataUpdate du WHERE du.TableName = @tableName AND du.UpdateType = @updateType ORDER BY du.StartDt DESC" : @"SELECT TOP (@count) du.Id, du.SourceSystem, du.SourceData, du.TableName, du.StartDt, du.EndDt, du.UpdateType, du.WasSuccessful, du.NumberRecords FROM dbo.DataUpdate du WHERE du.TableName = @tableName ORDER BY du.StartDt DESC"; await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken); var results = await connection.QueryAsync( sql, new { tableName, updateType = updateType.HasValue ? (int)updateType.Value : 0, count }, commandTimeout: 30); return results.ToList(); } ``` **Step 2: Implement GetLastRunsAsync** Add this method to `DataUpdateRepository`: ```csharp /// public async Task> GetLastRunsAsync( string tableName, CancellationToken cancellationToken = default) { const string sql = @" WITH LastRuns AS ( SELECT du.*, ROW_NUMBER() OVER (PARTITION BY du.UpdateType ORDER BY du.StartDt DESC) AS RN FROM dbo.DataUpdate du WHERE du.TableName = @tableName ) SELECT Id, SourceSystem, SourceData, TableName, StartDt, EndDt, UpdateType, WasSuccessful, NumberRecords FROM LastRuns WHERE RN = 1"; await using var connection = await _connectionFactory.CreateLotFinderConnectionAsync(cancellationToken); var results = await connection.QueryAsync(sql, new { tableName }, commandTimeout: 30); return results.ToDictionary(du => du.UpdateType, du => du); } ``` **Step 3: Verify build** ```bash dotnet build src/JdeScoping.DataSync ``` **Step 4: Run existing tests** ```bash dotnet test tests/JdeScoping.DataSync.Tests --filter "DataUpdateRepository" --no-build ``` **Step 5: Commit** ```bash git add src/JdeScoping.DataSync/Services/DataUpdateRepository.cs git commit -m "feat(datasync): implement GetRecentUpdatesAsync and GetLastRunsAsync" ``` --- ## Task 5: Create PipelineController **Files:** - Create: `src/JdeScoping.Api/Controllers/PipelineController.cs` **Step 1: Create the controller** ```csharp using JdeScoping.Core.ApiContracts; using JdeScoping.Core.ApiContracts.Pipelines; using JdeScoping.Core.Models.Enums; using JdeScoping.DataSync.Contracts; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace JdeScoping.Api.Controllers; /// /// API endpoints for pipeline configuration and status. /// [ApiController] [Route(ApiRoutes.Pipelines.Base)] [Authorize] public class PipelineController : ControllerBase { private readonly IEtlPipelineFactory _pipelineFactory; private readonly IDataUpdateRepository _dataUpdateRepository; public PipelineController( IEtlPipelineFactory pipelineFactory, IDataUpdateRepository dataUpdateRepository) { _pipelineFactory = pipelineFactory; _dataUpdateRepository = dataUpdateRepository; } /// /// Gets list of all available pipeline names. /// [HttpGet] public ActionResult GetPipelineNames() { var names = _pipelineFactory.GetAvailableTables() .OrderBy(n => n) .ToList(); return Ok(new PipelineListResponse(names)); } /// /// Gets configuration for a specific pipeline. /// [HttpGet(ApiRoutes.Pipelines.ByName)] public ActionResult GetPipeline(string name) { var config = _pipelineFactory.GetPipelineConfig(name); if (config is null) return NotFound(); var defaults = _pipelineFactory.GetScheduleDefaults(); var dto = MapToDto(name, config, defaults); return Ok(dto); } /// /// Gets schedule status for a pipeline. /// [HttpGet(ApiRoutes.Pipelines.Status)] public async Task> GetStatus( string name, CancellationToken cancellationToken) { var config = _pipelineFactory.GetPipelineConfig(name); if (config is null) return NotFound(); var tableName = config.Destination.Table; var lastRuns = await _dataUpdateRepository.GetLastRunsAsync(tableName, cancellationToken); var lastSuccessful = await _dataUpdateRepository.GetLastDataUpdatesAsync(cancellationToken); var defaults = _pipelineFactory.GetScheduleDefaults(); var statuses = new List(); foreach (var updateType in new[] { UpdateTypes.Mass, UpdateTypes.Daily, UpdateTypes.Hourly }) { var scheduleConfig = GetScheduleConfig(config, updateType); var interval = GetEffectiveInterval(scheduleConfig, defaults, updateType); lastRuns.TryGetValue(updateType, out var lastRun); var successKey = $"{tableName}_{(int)updateType}"; lastSuccessful.TryGetValue(successKey, out var lastSuccess); var nextRequired = lastSuccess?.EndDt.AddMinutes(interval); var isOverdue = DataUpdateRepository.IsOverdue( lastSuccess?.EndDt, tableName, updateType, null); statuses.Add(new PipelineScheduleStatusDto( updateType, lastRun?.StartDt, lastRun?.WasSuccessful ?? false, lastSuccess?.EndDt, nextRequired, isOverdue, interval)); } return Ok(new PipelineStatusResponse(statuses)); } /// /// Gets recent execution history for a pipeline. /// [HttpGet(ApiRoutes.Pipelines.Executions)] public async Task> GetExecutions( string name, [FromQuery] int count = 30, CancellationToken cancellationToken = default) { var config = _pipelineFactory.GetPipelineConfig(name); if (config is null) return NotFound(); var tableName = config.Destination.Table; var updates = await _dataUpdateRepository.GetRecentUpdatesAsync( tableName, null, count, cancellationToken); var executions = updates.Select(u => new PipelineExecutionDto( u.UpdateType, u.StartDt, u.EndDt == default ? null : u.EndDt, u.EndDt == default ? null : u.EndDt - u.StartDt, u.NumberRecords, u.WasSuccessful )).ToList(); return Ok(new PipelineExecutionsResponse(executions)); } private PipelineConfigDto MapToDto( string name, DataSync.Configuration.PipelineConfig config, DataSync.Configuration.ScheduleDefaults defaults) { var source = new PipelineSourceDto( config.Source.Connection, Truncate(config.Source.Query), Truncate(config.Source.MassQuery), config.Source.Query, config.Source.MassQuery, config.Source.Parameters?.Select(p => new PipelineParameterDto( p.Key, p.Value.Format, p.Value.Source)).ToList() ?? []); var matchCols = config.Destination.MatchColumns?.ToList(); var destination = new PipelineDestinationDto( config.Destination.Table, matchCols?.Count > 0 ? "BulkMerge" : "BulkImport", matchCols, config.Destination.ExcludeFromUpdate?.ToList()); var schedules = new PipelineSchedulesDto( MapSchedule(config.Schedules?.Mass, defaults.Mass), MapSchedule(config.Schedules?.Daily, defaults.Daily), MapSchedule(config.Schedules?.Hourly, defaults.Hourly)); return new PipelineConfigDto( name, source, destination, schedules, config.PreScripts?.Count ?? 0, config.PostScripts?.Count ?? 0, config.PreScripts, config.PostScripts); } private static PipelineScheduleDto MapSchedule( DataSync.Configuration.ScheduleConfig? config, DataSync.Configuration.ScheduleConfig defaults) { return new PipelineScheduleDto( config?.Enabled ?? defaults.Enabled ?? true, config?.IntervalMinutes ?? defaults.IntervalMinutes ?? 60, config?.PrePurge ?? defaults.PrePurge ?? false, config?.ReIndex ?? defaults.ReIndex ?? false, config?.IntervalMinutes.HasValue == true && config.IntervalMinutes != defaults.IntervalMinutes, config?.PrePurge.HasValue == true && config.PrePurge != defaults.PrePurge, config?.ReIndex.HasValue == true && config.ReIndex != defaults.ReIndex); } private static DataSync.Configuration.ScheduleConfig? GetScheduleConfig( DataSync.Configuration.PipelineConfig config, UpdateTypes updateType) => updateType switch { UpdateTypes.Mass => config.Schedules?.Mass, UpdateTypes.Daily => config.Schedules?.Daily, UpdateTypes.Hourly => config.Schedules?.Hourly, _ => null }; private static int GetEffectiveInterval( DataSync.Configuration.ScheduleConfig? config, DataSync.Configuration.ScheduleDefaults defaults, UpdateTypes updateType) { if (config?.IntervalMinutes.HasValue == true) return config.IntervalMinutes.Value; return updateType switch { UpdateTypes.Mass => defaults.Mass?.IntervalMinutes ?? 10080, UpdateTypes.Daily => defaults.Daily?.IntervalMinutes ?? 1440, UpdateTypes.Hourly => defaults.Hourly?.IntervalMinutes ?? 60, _ => 60 }; } private static string? Truncate(string? value, int maxLength = 100) => value is null ? null : value.Length <= maxLength ? value : value[..maxLength] + "..."; } ``` **Step 2: Verify IEtlPipelineFactory has needed methods** Check if `GetPipelineConfig` and `GetScheduleDefaults` exist. If not, we need to add them. **Step 3: Build and fix any issues** ```bash dotnet build src/JdeScoping.Api ``` **Step 4: Commit** ```bash git add src/JdeScoping.Api/Controllers/PipelineController.cs git commit -m "feat(api): add PipelineController for pipeline viewer" ``` --- ## Task 6: Add Missing Factory Methods (if needed) **Files:** - Modify: `src/JdeScoping.DataSync/Contracts/IEtlPipelineFactory.cs` - Modify: `src/JdeScoping.DataSync/Services/EtlPipelineFactory.cs` **Step 1: Check if methods exist** Read `IEtlPipelineFactory.cs` and check for `GetPipelineConfig` and `GetScheduleDefaults`. **Step 2: If missing, add to interface** ```csharp /// /// Gets the configuration for a specific pipeline. /// PipelineConfig? GetPipelineConfig(string tableName); /// /// Gets the schedule defaults. /// ScheduleDefaults GetScheduleDefaults(); ``` **Step 3: Implement in EtlPipelineFactory** ```csharp public PipelineConfig? GetPipelineConfig(string tableName) { return _config.Pipelines.TryGetValue(tableName, out var config) ? config : null; } public ScheduleDefaults GetScheduleDefaults() { return _config.ScheduleDefaults ?? new ScheduleDefaults( new ScheduleConfig { IntervalMinutes = 10080, PrePurge = true, ReIndex = true }, new ScheduleConfig { IntervalMinutes = 1440 }, new ScheduleConfig { IntervalMinutes = 60 }); } ``` **Step 4: Build** ```bash dotnet build src/JdeScoping.DataSync && dotnet build src/JdeScoping.Api ``` **Step 5: Commit** ```bash git add src/JdeScoping.DataSync/ git commit -m "feat(datasync): add GetPipelineConfig and GetScheduleDefaults to factory" ``` --- ## Task 7: Create Client API Service **Files:** - Create: `src/JdeScoping.Client/Services/IPipelineApiClient.cs` - Create: `src/JdeScoping.Client/Services/PipelineApiClient.cs` **Step 1: Create IPipelineApiClient.cs** ```csharp using JdeScoping.Core.ApiContracts.Pipelines; using JdeScoping.Core.ApiContracts.Results; namespace JdeScoping.Client.Services; /// /// Client for pipeline configuration API. /// public interface IPipelineApiClient { Task> GetPipelineNamesAsync(CancellationToken ct = default); Task> GetPipelineAsync(string name, CancellationToken ct = default); Task> GetStatusAsync(string name, CancellationToken ct = default); Task> GetExecutionsAsync(string name, int count = 30, CancellationToken ct = default); } ``` **Step 2: Create PipelineApiClient.cs** ```csharp using JdeScoping.Core.ApiContracts; using JdeScoping.Core.ApiContracts.Pipelines; using JdeScoping.Core.ApiContracts.Results; namespace JdeScoping.Client.Services; /// /// Client implementation for pipeline configuration API. /// public class PipelineApiClient : ApiClientBase, IPipelineApiClient { public PipelineApiClient(HttpClient httpClient) : base(httpClient) { } public Task> GetPipelineNamesAsync(CancellationToken ct = default) => GetAsync(ApiRoutes.Pipelines.Base, ct); public Task> GetPipelineAsync(string name, CancellationToken ct = default) => GetAsync(ApiRoutes.Pipelines.GetByName(name), ct); public Task> GetStatusAsync(string name, CancellationToken ct = default) => GetAsync(ApiRoutes.Pipelines.GetStatus(name), ct); public Task> GetExecutionsAsync(string name, int count = 30, CancellationToken ct = default) => GetAsync(ApiRoutes.Pipelines.GetExecutions(name, count), ct); } ``` **Step 3: Register in DI (Program.cs)** Add to client DI registration: ```csharp builder.Services.AddScoped(); ``` **Step 4: Build** ```bash dotnet build src/JdeScoping.Client ``` **Step 5: Commit** ```bash git add src/JdeScoping.Client/Services/IPipelineApiClient.cs src/JdeScoping.Client/Services/PipelineApiClient.cs git commit -m "feat(client): add PipelineApiClient" ``` --- ## Task 8: Create SqlQueryModal Component **Files:** - Create: `src/JdeScoping.Client/Components/Admin/SqlQueryModal.razor` **Step 1: Create Admin directory** ```bash mkdir -p src/JdeScoping.Client/Components/Admin ``` **Step 2: Create SqlQueryModal.razor** ```razor @namespace JdeScoping.Client.Components.Admin @Title
@FormattedSql
@code { [Parameter] public bool Visible { get; set; } [Parameter] public EventCallback VisibleChanged { get; set; } [Parameter] public string? Title { get; set; } [Parameter] public string? Sql { get; set; } [Inject] private IJSRuntime JS { get; set; } = default!; private string FormattedSql => FormatSql(Sql); private static string FormatSql(string? sql) { if (string.IsNullOrWhiteSpace(sql)) return ""; // Basic SQL formatting - add line breaks before major clauses return sql .Replace(" SELECT ", "\nSELECT ") .Replace(" FROM ", "\nFROM ") .Replace(" WHERE ", "\nWHERE ") .Replace(" AND ", "\n AND ") .Replace(" OR ", "\n OR ") .Replace(" LEFT ", "\nLEFT ") .Replace(" RIGHT ", "\nRIGHT ") .Replace(" INNER ", "\nINNER ") .Replace(" OUTER ", "\nOUTER ") .Replace(" JOIN ", " JOIN\n ") .Replace(" ORDER BY ", "\nORDER BY ") .Replace(" GROUP BY ", "\nGROUP BY ") .Replace(" HAVING ", "\nHAVING ") .Trim(); } private async Task CopyToClipboard() { if (!string.IsNullOrWhiteSpace(Sql)) { await JS.InvokeVoidAsync("navigator.clipboard.writeText", Sql); } } private async Task Close() { await VisibleChanged.InvokeAsync(false); } } ``` **Step 3: Build** ```bash dotnet build src/JdeScoping.Client ``` **Step 4: Commit** ```bash git add src/JdeScoping.Client/Components/Admin/ git commit -m "feat(client): add SqlQueryModal component" ``` --- ## Task 9: Create PipelineScheduleSection Component **Files:** - Create: `src/JdeScoping.Client/Components/Admin/PipelineScheduleSection.razor` **Step 1: Create PipelineScheduleSection.razor** ```razor @namespace JdeScoping.Client.Components.Admin @using JdeScoping.Core.ApiContracts.Pipelines @using JdeScoping.Core.Models.Enums @GetScheduleTypeName(ScheduleType) Refresh @if (Config?.Enabled == true) { } else { } @if (Config is not null) { Schedule Settings
Setting Value Source
Interval @FormatInterval(Config.IntervalMinutes) @(Config.IntervalIsOverride ? "Override" : "Default")
Pre-Purge @(Config.PrePurge ? "Yes" : "No") @(Config.PrePurgeIsOverride ? "Override" : "Default")
Re-Index @(Config.ReIndex ? "Yes" : "No") @(Config.ReIndexIsOverride ? "Override" : "Default")
@if (!string.IsNullOrWhiteSpace(QueryPreview)) { Query
@QueryPreview
@if (!string.IsNullOrWhiteSpace(FullQuery)) { } } }
@code { [Parameter] public UpdateTypes ScheduleType { get; set; } [Parameter] public PipelineScheduleDto? Config { get; set; } [Parameter] public string? QueryPreview { get; set; } [Parameter] public string? FullQuery { get; set; } [Parameter] public EventCallback OnViewQuery { get; set; } private static string GetScheduleTypeName(UpdateTypes type) => type switch { UpdateTypes.Mass => "Mass", UpdateTypes.Daily => "Daily", UpdateTypes.Hourly => "Hourly", _ => type.ToString() }; private static string FormatInterval(int minutes) { if (minutes >= 1440) return $"{minutes / 1440} day(s) ({minutes} min)"; if (minutes >= 60) return $"{minutes / 60} hour(s) ({minutes} min)"; return $"{minutes} minutes"; } } ``` **Step 2: Build** ```bash dotnet build src/JdeScoping.Client ``` **Step 3: Commit** ```bash git add src/JdeScoping.Client/Components/Admin/PipelineScheduleSection.razor git commit -m "feat(client): add PipelineScheduleSection component" ``` --- ## Task 10: Create PipelineViewer Page **Files:** - Create: `src/JdeScoping.Client/Pages/Admin/PipelineViewer.razor` **Step 1: Create Admin directory** ```bash mkdir -p src/JdeScoping.Client/Pages/Admin ``` **Step 2: Create PipelineViewer.razor** ```razor @page "/admin/pipeline-viewer" @attribute [Authorize] @using JdeScoping.Client.Components.Admin @using JdeScoping.Client.Components.Shared @using JdeScoping.Core.ApiContracts.Pipelines @using JdeScoping.Core.Models.Enums @inject IPipelineApiClient PipelineApi Pipeline Configuration Viewer - JDE Scoping Tool ETL Pipeline Configuration Viewer @if (_isLoading) { } else if (_config is not null) { Schedule Status Summary Recent Execution History Source

Connection: @switch (_config.Source.Connection.ToLower()) { case "jde": break; case "cms": break; case "giw": break; default: break; }

@if (_config.Source.Parameters.Count > 0) {

Parameters:

    @foreach (var param in _config.Source.Parameters) {
  • @param.Name (@(param.Format ?? "default"))
  • }
}
Destination

Table: @_config.Destination.Table

Operation:

@if (_config.Destination.MatchColumns?.Count > 0) {

Match Columns: @string.Join(", ", _config.Destination.MatchColumns)

} @if (_config.Destination.ExcludeFromUpdate?.Count > 0) {

Exclude: @string.Join(", ", _config.Destination.ExcludeFromUpdate)

}
Scripts

Pre-Scripts: @_config.PreScriptCount

Post-Scripts: @_config.PostScriptCount

@if (_config.PreScripts?.Count > 0) { Pre-Scripts: @for (int i = 0; i < _config.PreScripts.Count; i++) { var script = _config.PreScripts[i]; var index = i + 1;
} } @if (_config.PostScripts?.Count > 0) { Post-Scripts: @for (int i = 0; i < _config.PostScripts.Count; i++) { var script = _config.PostScripts[i]; var index = i + 1;
} }
} @code { private List _pipelineNames = []; private string? _selectedPipeline; private bool _isLoading; private PipelineConfigDto? _config; private List _statuses = []; private List _executions = []; private bool _showSqlModal; private string? _sqlModalTitle; private string? _sqlModalContent; protected override async Task OnInitializedAsync() { var result = await PipelineApi.GetPipelineNamesAsync(); if (result.TryGetValue(out var response)) { _pipelineNames = response.PipelineNames; } } private async Task OnPipelineChanged() { if (string.IsNullOrWhiteSpace(_selectedPipeline)) { _config = null; _statuses = []; _executions = []; return; } _isLoading = true; try { var configTask = PipelineApi.GetPipelineAsync(_selectedPipeline); var statusTask = PipelineApi.GetStatusAsync(_selectedPipeline); var executionsTask = PipelineApi.GetExecutionsAsync(_selectedPipeline); await Task.WhenAll(configTask, statusTask, executionsTask); if (configTask.Result.TryGetValue(out var config)) _config = config; if (statusTask.Result.TryGetValue(out var status)) _statuses = status.Statuses; if (executionsTask.Result.TryGetValue(out var execs)) _executions = execs.Executions; } finally { _isLoading = false; } } private void ShowSqlModal(string title, string sql) { _sqlModalTitle = $"{title} - {_selectedPipeline}"; _sqlModalContent = sql; _showSqlModal = true; } private static string FormatDuration(TimeSpan? duration) { if (!duration.HasValue) return "-"; var d = duration.Value; if (d.TotalHours >= 1) return $"{d.Hours}h {d.Minutes}m"; if (d.TotalMinutes >= 1) return $"{d.Minutes}m {d.Seconds}s"; return $"{d.Seconds}s"; } } ``` **Step 3: Build** ```bash dotnet build src/JdeScoping.Client ``` **Step 4: Commit** ```bash git add src/JdeScoping.Client/Pages/Admin/ git commit -m "feat(client): add PipelineViewer page" ``` --- ## Task 11: Register Services and Build Full Solution **Files:** - Modify: `src/JdeScoping.Client/Program.cs` **Step 1: Register PipelineApiClient** Add to Program.cs DI section: ```csharp builder.Services.AddScoped(); ``` **Step 2: Build full solution** ```bash dotnet build ``` **Step 3: Run tests** ```bash dotnet test ``` **Step 4: Commit** ```bash git add src/JdeScoping.Client/Program.cs git commit -m "feat(client): register PipelineApiClient in DI" ``` --- ## Task 12: Add Navigation Link (Optional) **Files:** - Modify: `src/JdeScoping.Client/Layout/MainLayout.razor` (or NavMenu) **Step 1: Add link to admin section** Find navigation and add: ```razor ``` **Step 2: Build and test manually** ```bash dotnet run --project src/JdeScoping.Host ``` Navigate to `/admin/pipeline-viewer` in browser. **Step 3: Commit** ```bash git add src/JdeScoping.Client/ git commit -m "feat(client): add pipeline viewer to navigation" ``` --- ## Final Verification ```bash # Build dotnet build # Run tests dotnet test # Start application dotnet run --project src/JdeScoping.Host ``` Navigate to `/admin/pipeline-viewer`, select a pipeline, and verify: - Pipeline list loads - Status summary shows - Execution history shows - Configuration cards display - Schedule sections show with Default/Override indicators - SQL modal works with copy button