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