From ff487aa99c13b54296cada5b0a84c5934e27ad65 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 7 Jan 2026 07:53:54 -0500 Subject: [PATCH] docs: add pipeline viewer implementation plan Detailed step-by-step implementation tasks: 1. Create Pipeline DTOs in Core 2. Add Pipeline Routes to ApiRoutes 3. Add Repository Methods 4. Implement Repository Methods 5. Create PipelineController 6. Add Missing Factory Methods 7. Create Client API Service 8. Create SqlQueryModal Component 9. Create PipelineScheduleSection Component 10. Create PipelineViewer Page 11. Register Services 12. Add Navigation Link --- ...26-01-07-pipeline-viewer-implementation.md | 1327 +++++++++++++++++ 1 file changed, 1327 insertions(+) create mode 100644 NEW/docs/plans/2026-01-07-pipeline-viewer-implementation.md diff --git a/NEW/docs/plans/2026-01-07-pipeline-viewer-implementation.md b/NEW/docs/plans/2026-01-07-pipeline-viewer-implementation.md new file mode 100644 index 0000000..dec6b51 --- /dev/null +++ b/NEW/docs/plans/2026-01-07-pipeline-viewer-implementation.md @@ -0,0 +1,1327 @@ +# 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 + + + + + + + + + + + + + + + + + + + + + + + + + +
SettingValueSource
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