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
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"
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:
<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