Files
jdescopingtool/NEW/docs/plans/2026-01-07-pipeline-viewer-implementation.md
T
Joseph Doherty ff487aa99c 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
2026-01-07 07:53:54 -05:00

45 KiB

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

mkdir -p src/JdeScoping.Core/ApiContracts/Pipelines

Step 2: Create PipelineListResponse.cs

namespace JdeScoping.Core.ApiContracts.Pipelines;

/// <summary>
/// Response containing list of available pipeline names.
/// </summary>
public record PipelineListResponse(List<string> PipelineNames);

Step 3: Create PipelineConfigDto.cs

namespace JdeScoping.Core.ApiContracts.Pipelines;

/// <summary>
/// Pipeline configuration DTO for display.
/// </summary>
public record PipelineConfigDto(
    string Name,
    PipelineSourceDto Source,
    PipelineDestinationDto Destination,
    PipelineSchedulesDto Schedules,
    int PreScriptCount,
    int PostScriptCount,
    List<string>? PreScripts,
    List<string>? PostScripts);

public record PipelineSourceDto(
    string Connection,
    string? QueryPreview,
    string? MassQueryPreview,
    string? Query,
    string? MassQuery,
    List<PipelineParameterDto> Parameters);

public record PipelineParameterDto(
    string Name,
    string? Format,
    string Source);

public record PipelineDestinationDto(
    string Table,
    string OperationType,
    List<string>? MatchColumns,
    List<string>? 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

namespace JdeScoping.Core.ApiContracts.Pipelines;

using JdeScoping.Core.Models.Enums;

/// <summary>
/// Pipeline schedule status for each update type.
/// </summary>
public record PipelineStatusResponse(List<PipelineScheduleStatusDto> Statuses);

public record PipelineScheduleStatusDto(
    UpdateTypes ScheduleType,
    DateTime? LastRun,
    bool LastRunWasSuccessful,
    DateTime? LastSuccessfulRun,
    DateTime? NextRequiredRun,
    bool IsOverdue,
    int IntervalMinutes);

Step 5: Create PipelineExecutionDto.cs

namespace JdeScoping.Core.ApiContracts.Pipelines;

using JdeScoping.Core.Models.Enums;

/// <summary>
/// Pipeline execution history.
/// </summary>
public record PipelineExecutionsResponse(List<PipelineExecutionDto> Executions);

public record PipelineExecutionDto(
    UpdateTypes ScheduleType,
    DateTime StartTime,
    DateTime? EndTime,
    TimeSpan? Duration,
    long RecordCount,
    bool WasSuccessful);

Step 6: Verify build

dotnet build src/JdeScoping.Core

Step 7: Commit

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:

    /// <summary>
    /// Routes for pipeline configuration API endpoints.
    /// </summary>
    public static class Pipelines
    {
        /// <summary>Base route for pipeline endpoints.</summary>
        public const string Base = "api/pipelines";

        /// <summary>Route template for getting a pipeline by name.</summary>
        public const string ByName = "{name}";

        /// <summary>Route template for getting pipeline status.</summary>
        public const string Status = "{name}/status";

        /// <summary>Route template for getting pipeline executions.</summary>
        public const string Executions = "{name}/executions";

        /// <summary>Builds the route to get a specific pipeline config.</summary>
        public static string GetByName(string name) => $"api/pipelines/{Uri.EscapeDataString(name)}";

        /// <summary>Builds the route to get pipeline status.</summary>
        public static string GetStatus(string name) => $"api/pipelines/{Uri.EscapeDataString(name)}/status";

        /// <summary>Builds the route to get pipeline executions.</summary>
        public static string GetExecutions(string name, int count = 10) =>
            $"api/pipelines/{Uri.EscapeDataString(name)}/executions?count={count}";
    }

Step 2: Verify build

dotnet build src/JdeScoping.Core

Step 3: Commit

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:

    /// <summary>
    /// Gets the last N execution records for a specific table.
    /// </summary>
    /// <param name="tableName">The table name to filter by.</param>
    /// <param name="updateType">Optional update type filter. If null, returns all types.</param>
    /// <param name="count">Maximum records to return (total, not per type).</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>List of DataUpdate records ordered by StartDt descending.</returns>
    Task<List<DataUpdate>> GetRecentUpdatesAsync(
        string tableName,
        UpdateTypes? updateType = null,
        int count = 30,
        CancellationToken cancellationToken = default);

    /// <summary>
    /// Gets the last run (successful or not) for each update type for a table.
    /// </summary>
    /// <param name="tableName">The table name.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Dictionary keyed by UpdateType.</returns>
    Task<Dictionary<UpdateTypes, DataUpdate>> GetLastRunsAsync(
        string tableName,
        CancellationToken cancellationToken = default);

Step 2: Verify build

dotnet build src/JdeScoping.DataSync

Expected: Build fails (not implemented yet)

Step 3: Commit interface changes

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:

    /// <inheritdoc/>
    public async Task<List<DataUpdate>> 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<DataUpdate>(
            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:

    /// <inheritdoc/>
    public async Task<Dictionary<UpdateTypes, DataUpdate>> 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<DataUpdate>(sql, new { tableName }, commandTimeout: 30);

        return results.ToDictionary(du => du.UpdateType, du => du);
    }

Step 3: Verify build

dotnet build src/JdeScoping.DataSync

Step 4: Run existing tests

dotnet test tests/JdeScoping.DataSync.Tests --filter "DataUpdateRepository" --no-build

Step 5: Commit

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

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;

/// <summary>
/// API endpoints for pipeline configuration and status.
/// </summary>
[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;
    }

    /// <summary>
    /// Gets list of all available pipeline names.
    /// </summary>
    [HttpGet]
    public ActionResult<PipelineListResponse> GetPipelineNames()
    {
        var names = _pipelineFactory.GetAvailableTables()
            .OrderBy(n => n)
            .ToList();
        return Ok(new PipelineListResponse(names));
    }

    /// <summary>
    /// Gets configuration for a specific pipeline.
    /// </summary>
    [HttpGet(ApiRoutes.Pipelines.ByName)]
    public ActionResult<PipelineConfigDto> 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);
    }

    /// <summary>
    /// Gets schedule status for a pipeline.
    /// </summary>
    [HttpGet(ApiRoutes.Pipelines.Status)]
    public async Task<ActionResult<PipelineStatusResponse>> 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<PipelineScheduleStatusDto>();
        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));
    }

    /// <summary>
    /// Gets recent execution history for a pipeline.
    /// </summary>
    [HttpGet(ApiRoutes.Pipelines.Executions)]
    public async Task<ActionResult<PipelineExecutionsResponse>> 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

dotnet build src/JdeScoping.Api

Step 4: Commit

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

    /// <summary>
    /// Gets the configuration for a specific pipeline.
    /// </summary>
    PipelineConfig? GetPipelineConfig(string tableName);

    /// <summary>
    /// Gets the schedule defaults.
    /// </summary>
    ScheduleDefaults GetScheduleDefaults();

Step 3: Implement in EtlPipelineFactory

    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

dotnet build src/JdeScoping.DataSync && dotnet build src/JdeScoping.Api

Step 5: Commit

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

using JdeScoping.Core.ApiContracts.Pipelines;
using JdeScoping.Core.ApiContracts.Results;

namespace JdeScoping.Client.Services;

/// <summary>
/// Client for pipeline configuration API.
/// </summary>
public interface IPipelineApiClient
{
    Task<ApiResult<PipelineListResponse>> GetPipelineNamesAsync(CancellationToken ct = default);
    Task<ApiResult<PipelineConfigDto>> GetPipelineAsync(string name, CancellationToken ct = default);
    Task<ApiResult<PipelineStatusResponse>> GetStatusAsync(string name, CancellationToken ct = default);
    Task<ApiResult<PipelineExecutionsResponse>> GetExecutionsAsync(string name, int count = 30, CancellationToken ct = default);
}

Step 2: Create PipelineApiClient.cs

using JdeScoping.Core.ApiContracts;
using JdeScoping.Core.ApiContracts.Pipelines;
using JdeScoping.Core.ApiContracts.Results;

namespace JdeScoping.Client.Services;

/// <summary>
/// Client implementation for pipeline configuration API.
/// </summary>
public class PipelineApiClient : ApiClientBase, IPipelineApiClient
{
    public PipelineApiClient(HttpClient httpClient) : base(httpClient) { }

    public Task<ApiResult<PipelineListResponse>> GetPipelineNamesAsync(CancellationToken ct = default)
        => GetAsync<PipelineListResponse>(ApiRoutes.Pipelines.Base, ct);

    public Task<ApiResult<PipelineConfigDto>> GetPipelineAsync(string name, CancellationToken ct = default)
        => GetAsync<PipelineConfigDto>(ApiRoutes.Pipelines.GetByName(name), ct);

    public Task<ApiResult<PipelineStatusResponse>> GetStatusAsync(string name, CancellationToken ct = default)
        => GetAsync<PipelineStatusResponse>(ApiRoutes.Pipelines.GetStatus(name), ct);

    public Task<ApiResult<PipelineExecutionsResponse>> GetExecutionsAsync(string name, int count = 30, CancellationToken ct = default)
        => GetAsync<PipelineExecutionsResponse>(ApiRoutes.Pipelines.GetExecutions(name, count), ct);
}

Step 3: Register in DI (Program.cs)

Add to client DI registration:

builder.Services.AddScoped<IPipelineApiClient, PipelineApiClient>();

Step 4: Build

dotnet build src/JdeScoping.Client

Step 5: Commit

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

mkdir -p src/JdeScoping.Client/Components/Admin

Step 2: Create SqlQueryModal.razor

@namespace JdeScoping.Client.Components.Admin

<RadzenDialog Visible="@Visible" Style="width: 80vw; max-width: 1200px;" ShowClose="true" CloseOnEscapeKey="true">
    <TitleContent>
        <RadzenText TextStyle="TextStyle.H5">@Title</RadzenText>
    </TitleContent>
    <ChildContent>
        <div class="sql-container" style="max-height: 60vh; overflow: auto; background: #f5f5f5; padding: 1rem; border-radius: 4px;">
            <pre style="margin: 0; white-space: pre-wrap; word-break: break-word; font-family: 'Consolas', 'Monaco', monospace; font-size: 0.875rem;">@FormattedSql</pre>
        </div>
    </ChildContent>
    <FooterContent>
        <RadzenButton Text="Copy to Clipboard" Icon="content_copy" ButtonStyle="ButtonStyle.Secondary" Click="CopyToClipboard" class="rz-mr-2" />
        <RadzenButton Text="Close" Click="Close" />
    </FooterContent>
</RadzenDialog>

@code {
    [Parameter] public bool Visible { get; set; }
    [Parameter] public EventCallback<bool> 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

dotnet build src/JdeScoping.Client

Step 4: Commit

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

@namespace JdeScoping.Client.Components.Admin
@using JdeScoping.Core.ApiContracts.Pipelines
@using JdeScoping.Core.Models.Enums

<RadzenCard class="rz-mb-4">
    <RadzenRow AlignItems="AlignItems.Center" class="rz-mb-3">
        <RadzenColumn>
            <RadzenText TextStyle="TextStyle.H5" class="rz-m-0">
                @GetScheduleTypeName(ScheduleType) Refresh
            </RadzenText>
        </RadzenColumn>
        <RadzenColumn Size="2" class="rz-text-align-end">
            @if (Config?.Enabled == true)
            {
                <RadzenBadge BadgeStyle="BadgeStyle.Success" Text="Enabled" />
            }
            else
            {
                <RadzenBadge BadgeStyle="BadgeStyle.Light" Text="Disabled" />
            }
        </RadzenColumn>
    </RadzenRow>

    @if (Config is not null)
    {
        <RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mb-2">Schedule Settings</RadzenText>
        <table class="rz-mb-4" style="width: 100%; border-collapse: collapse;">
            <thead>
                <tr style="border-bottom: 1px solid #dee2e6;">
                    <th style="text-align: left; padding: 0.5rem;">Setting</th>
                    <th style="text-align: left; padding: 0.5rem;">Value</th>
                    <th style="text-align: left; padding: 0.5rem;">Source</th>
                </tr>
            </thead>
            <tbody>
                <tr style="border-bottom: 1px solid #dee2e6;">
                    <td style="padding: 0.5rem;">Interval</td>
                    <td style="padding: 0.5rem;">@FormatInterval(Config.IntervalMinutes)</td>
                    <td style="padding: 0.5rem;">@(Config.IntervalIsOverride ? "Override" : "Default")</td>
                </tr>
                <tr style="border-bottom: 1px solid #dee2e6;">
                    <td style="padding: 0.5rem;">Pre-Purge</td>
                    <td style="padding: 0.5rem;">@(Config.PrePurge ? "Yes" : "No")</td>
                    <td style="padding: 0.5rem;">@(Config.PrePurgeIsOverride ? "Override" : "Default")</td>
                </tr>
                <tr style="border-bottom: 1px solid #dee2e6;">
                    <td style="padding: 0.5rem;">Re-Index</td>
                    <td style="padding: 0.5rem;">@(Config.ReIndex ? "Yes" : "No")</td>
                    <td style="padding: 0.5rem;">@(Config.ReIndexIsOverride ? "Override" : "Default")</td>
                </tr>
            </tbody>
        </table>

        @if (!string.IsNullOrWhiteSpace(QueryPreview))
        {
            <RadzenText TextStyle="TextStyle.Subtitle1" class="rz-mb-2">Query</RadzenText>
            <div style="background: #f5f5f5; padding: 0.75rem; border-radius: 4px; font-family: monospace; font-size: 0.875rem;">
                @QueryPreview
            </div>
            @if (!string.IsNullOrWhiteSpace(FullQuery))
            {
                <RadzenButton Text="View Full Query" Icon="open_in_new" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
                              Click="@(() => OnViewQuery.InvokeAsync(FullQuery))" class="rz-mt-2" />
            }
        }
    }
</RadzenCard>

@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<string> 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

dotnet build src/JdeScoping.Client

Step 3: Commit

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

mkdir -p src/JdeScoping.Client/Pages/Admin

Step 2: Create PipelineViewer.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

<PageTitle>Pipeline Configuration Viewer - JDE Scoping Tool</PageTitle>

<RadzenText TextStyle="TextStyle.H4" class="rz-mb-4">ETL Pipeline Configuration Viewer</RadzenText>

<!-- Pipeline Selector -->
<RadzenCard class="rz-mb-4">
    <RadzenFormField Text="Select Pipeline" Style="width: 400px;">
        <RadzenDropDown @bind-Value="_selectedPipeline" Data="@_pipelineNames" Style="width: 100%;"
                        Change="@OnPipelineChanged" Placeholder="Select a pipeline..." />
    </RadzenFormField>
</RadzenCard>

@if (_isLoading)
{
    <LoadingIndicator Message="Loading pipeline data..." />
}
else if (_config is not null)
{
    <!-- Status Summary Table -->
    <RadzenCard class="rz-mb-4">
        <RadzenText TextStyle="TextStyle.H5" class="rz-mb-3">Schedule Status Summary</RadzenText>
        <RadzenDataGrid Data="@_statuses" TItem="PipelineScheduleStatusDto">
            <Columns>
                <RadzenDataGridColumn TItem="PipelineScheduleStatusDto" Property="ScheduleType" Title="Type" Width="100px">
                    <Template Context="item">@item.ScheduleType.ToString()</Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineScheduleStatusDto" Title="Last Run" Width="160px">
                    <Template Context="item">@(item.LastRun?.ToString("MM/dd/yyyy hh:mm tt") ?? "Never")</Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineScheduleStatusDto" Title="Success?" Width="80px" TextAlign="TextAlign.Center">
                    <Template Context="item">
                        @if (item.LastRun.HasValue)
                        {
                            @if (item.LastRunWasSuccessful)
                            {
                                <RadzenIcon Icon="check_circle" Style="color: green;" />
                            }
                            else
                            {
                                <RadzenIcon Icon="cancel" Style="color: red;" />
                            }
                        }
                    </Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineScheduleStatusDto" Title="Next Required" Width="160px">
                    <Template Context="item">@(item.NextRequiredRun?.ToString("MM/dd/yyyy hh:mm tt") ?? "N/A")</Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineScheduleStatusDto" Title="Status" Width="100px" TextAlign="TextAlign.Center">
                    <Template Context="item">
                        @if (item.IsOverdue)
                        {
                            <RadzenBadge BadgeStyle="BadgeStyle.Warning" Text="Overdue" />
                        }
                        else
                        {
                            <RadzenBadge BadgeStyle="BadgeStyle.Success" Text="OK" />
                        }
                    </Template>
                </RadzenDataGridColumn>
            </Columns>
        </RadzenDataGrid>
    </RadzenCard>

    <!-- Execution History Table -->
    <RadzenCard class="rz-mb-4">
        <RadzenText TextStyle="TextStyle.H5" class="rz-mb-3">Recent Execution History</RadzenText>
        <RadzenDataGrid Data="@_executions" TItem="PipelineExecutionDto" AllowSorting="true" PageSize="10" AllowPaging="true">
            <Columns>
                <RadzenDataGridColumn TItem="PipelineExecutionDto" Property="ScheduleType" Title="Type" Width="100px">
                    <Template Context="item">@item.ScheduleType.ToString()</Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineExecutionDto" Title="Start" Width="160px">
                    <Template Context="item">@item.StartTime.ToString("MM/dd/yyyy hh:mm tt")</Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineExecutionDto" Title="End" Width="160px">
                    <Template Context="item">@(item.EndTime?.ToString("MM/dd/yyyy hh:mm tt") ?? "In Progress")</Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineExecutionDto" Title="Duration" Width="100px">
                    <Template Context="item">@FormatDuration(item.Duration)</Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineExecutionDto" Property="RecordCount" Title="Records" Width="100px" TextAlign="TextAlign.Right">
                    <Template Context="item">@item.RecordCount.ToString("N0")</Template>
                </RadzenDataGridColumn>
                <RadzenDataGridColumn TItem="PipelineExecutionDto" Title="Result" Width="100px" TextAlign="TextAlign.Center">
                    <Template Context="item">
                        @if (item.WasSuccessful)
                        {
                            <RadzenBadge BadgeStyle="BadgeStyle.Success" Text="Success" />
                        }
                        else
                        {
                            <RadzenBadge BadgeStyle="BadgeStyle.Danger" Text="Failed" />
                        }
                    </Template>
                </RadzenDataGridColumn>
            </Columns>
        </RadzenDataGrid>
    </RadzenCard>

    <!-- Common Pipeline Info -->
    <RadzenRow Gap="1rem" class="rz-mb-4">
        <!-- Source Card -->
        <RadzenColumn Size="4">
            <RadzenCard Style="height: 100%;">
                <RadzenText TextStyle="TextStyle.H6" class="rz-mb-2">Source</RadzenText>
                <p><strong>Connection:</strong>
                    @switch (_config.Source.Connection.ToLower())
                    {
                        case "jde":
                            <RadzenBadge BadgeStyle="BadgeStyle.Info" Text="JDE" />
                            break;
                        case "cms":
                            <RadzenBadge BadgeStyle="BadgeStyle.Success" Text="CMS" />
                            break;
                        case "giw":
                            <RadzenBadge BadgeStyle="BadgeStyle.Warning" Text="GIW" />
                            break;
                        default:
                            <RadzenBadge Text="@_config.Source.Connection" />
                            break;
                    }
                </p>
                @if (_config.Source.Parameters.Count > 0)
                {
                    <p><strong>Parameters:</strong></p>
                    <ul>
                        @foreach (var param in _config.Source.Parameters)
                        {
                            <li>@param.Name (@(param.Format ?? "default"))</li>
                        }
                    </ul>
                }
            </RadzenCard>
        </RadzenColumn>

        <!-- Destination Card -->
        <RadzenColumn Size="4">
            <RadzenCard Style="height: 100%;">
                <RadzenText TextStyle="TextStyle.H6" class="rz-mb-2">Destination</RadzenText>
                <p><strong>Table:</strong> @_config.Destination.Table</p>
                <p><strong>Operation:</strong>
                    <RadzenBadge BadgeStyle="@(_config.Destination.OperationType == "BulkMerge" ? BadgeStyle.Primary : BadgeStyle.Secondary)"
                                 Text="@_config.Destination.OperationType" />
                </p>
                @if (_config.Destination.MatchColumns?.Count > 0)
                {
                    <p><strong>Match Columns:</strong> @string.Join(", ", _config.Destination.MatchColumns)</p>
                }
                @if (_config.Destination.ExcludeFromUpdate?.Count > 0)
                {
                    <p><strong>Exclude:</strong> @string.Join(", ", _config.Destination.ExcludeFromUpdate)</p>
                }
            </RadzenCard>
        </RadzenColumn>

        <!-- Scripts Card -->
        <RadzenColumn Size="4">
            <RadzenCard Style="height: 100%;">
                <RadzenText TextStyle="TextStyle.H6" class="rz-mb-2">Scripts</RadzenText>
                <p><strong>Pre-Scripts:</strong> @_config.PreScriptCount</p>
                <p><strong>Post-Scripts:</strong> @_config.PostScriptCount</p>
                @if (_config.PreScripts?.Count > 0)
                {
                    <RadzenText TextStyle="TextStyle.Subtitle2" class="rz-mt-2">Pre-Scripts:</RadzenText>
                    @for (int i = 0; i < _config.PreScripts.Count; i++)
                    {
                        var script = _config.PreScripts[i];
                        var index = i + 1;
                        <div>
                            <RadzenButton Text="@($"Script {index}")" Icon="code" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
                                          Click="@(() => ShowSqlModal($"Pre-Script {index}", script))" />
                        </div>
                    }
                }
                @if (_config.PostScripts?.Count > 0)
                {
                    <RadzenText TextStyle="TextStyle.Subtitle2" class="rz-mt-2">Post-Scripts:</RadzenText>
                    @for (int i = 0; i < _config.PostScripts.Count; i++)
                    {
                        var script = _config.PostScripts[i];
                        var index = i + 1;
                        <div>
                            <RadzenButton Text="@($"Script {index}")" Icon="code" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
                                          Click="@(() => ShowSqlModal($"Post-Script {index}", script))" />
                        </div>
                    }
                }
            </RadzenCard>
        </RadzenColumn>
    </RadzenRow>

    <!-- Schedule Sections -->
    <PipelineScheduleSection ScheduleType="UpdateTypes.Mass" Config="@_config.Schedules.Mass"
                             QueryPreview="@_config.Source.MassQueryPreview" FullQuery="@_config.Source.MassQuery"
                             OnViewQuery="@(q => ShowSqlModal("Mass Query", q))" />

    <PipelineScheduleSection ScheduleType="UpdateTypes.Daily" Config="@_config.Schedules.Daily"
                             QueryPreview="@_config.Source.QueryPreview" FullQuery="@_config.Source.Query"
                             OnViewQuery="@(q => ShowSqlModal("Daily Query", q))" />

    <PipelineScheduleSection ScheduleType="UpdateTypes.Hourly" Config="@_config.Schedules.Hourly"
                             QueryPreview="@_config.Source.QueryPreview" FullQuery="@_config.Source.Query"
                             OnViewQuery="@(q => ShowSqlModal("Hourly Query", q))" />
}

<SqlQueryModal @bind-Visible="_showSqlModal" Title="@_sqlModalTitle" Sql="@_sqlModalContent" />

@code {
    private List<string> _pipelineNames = [];
    private string? _selectedPipeline;
    private bool _isLoading;
    private PipelineConfigDto? _config;
    private List<PipelineScheduleStatusDto> _statuses = [];
    private List<PipelineExecutionDto> _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

dotnet build src/JdeScoping.Client

Step 4: Commit

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:

builder.Services.AddScoped<IPipelineApiClient, PipelineApiClient>();

Step 2: Build full solution

dotnet build

Step 3: Run tests

dotnet test

Step 4: Commit

git add src/JdeScoping.Client/Program.cs
git commit -m "feat(client): register PipelineApiClient in DI"

Files:

  • Modify: src/JdeScoping.Client/Layout/MainLayout.razor (or NavMenu)

Step 1: Add link to admin section

Find navigation and add:

<RadzenPanelMenuItem Text="Pipeline Viewer" Path="admin/pipeline-viewer" Icon="settings_ethernet" />

Step 2: Build and test manually

dotnet run --project src/JdeScoping.Host

Navigate to /admin/pipeline-viewer in browser.

Step 3: Commit

git add src/JdeScoping.Client/
git commit -m "feat(client): add pipeline viewer to navigation"

Final Verification

# 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