diff --git a/README.md b/README.md index 44436e0..a596653 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ O host `hosts/PythonScriptRunner.WorkerHost` é um worker pronto, configurado po Com `PythonScriptRunner.Dashboard`, o painel é embutido na sua aplicação, no estilo do Hangfire: API interna, atualizações em tempo real (SignalR) e a interface Vue servida no prefixo escolhido. Sem `MapPythonScriptRunnerDashboard` não existe painel. -A interface (em pt-BR, tema claro/escuro) cobre a visão geral, Etapas (editor Monaco com construtor de campos, testar -pela fila e publicar), Trilhas (editor visual com *handles* tipados, ou lista editável, testar e publicar), execuções ao +A interface (em pt-BR, tema claro/escuro) cobre a visão geral, Etapas (lista filtrável por trilha, editor Monaco com +construtor de campos, testar pela fila e publicar), Trilhas (editor visual com *handles* tipados, ou lista editável, testar e publicar), execuções ao vivo (fluxo por status, logs, arquivos, linha do tempo, cancelar e reprocessar), agendamentos com prévia do cron, auditoria e administração. Ela vai dentro do pacote: não há nada para hospedar à parte. diff --git a/panel/src/api/schema.d.ts b/panel/src/api/schema.d.ts index 96cf49d..4a0c729 100644 --- a/panel/src/api/schema.d.ts +++ b/panel/src/api/schema.d.ts @@ -2240,6 +2240,7 @@ export interface operations { query?: { search?: string; status?: components["schemas"]["VersionStatus"]; + pipelineKey?: string; page?: number; pageSize?: number; }; diff --git a/panel/src/components/common/CatalogList.vue b/panel/src/components/common/CatalogList.vue index fc7430d..a660d0c 100644 --- a/panel/src/components/common/CatalogList.vue +++ b/panel/src/components/common/CatalogList.vue @@ -1,7 +1,7 @@ + + diff --git a/panel/src/views/pipelines/PipelineDetailView.vue b/panel/src/views/pipelines/PipelineDetailView.vue index 06b1064..c431a72 100644 --- a/panel/src/views/pipelines/PipelineDetailView.vue +++ b/panel/src/views/pipelines/PipelineDetailView.vue @@ -201,6 +201,14 @@ const exportUrl = (v: number) => `${apiBase}/pipelines/${encodeURIComponent(key. > {{ t('pipelines.run.open') }} + + {{ t('pipelines.stagesLink') }} + SetScheduleEnabledAsync( /// 1-based page number. /// Items per page (1-100). /// Only items that have a version with this status. -public sealed record CatalogQuery(string? Search = null, int PageNumber = 1, int PageSize = 25, VersionStatus? Status = null); +/// +/// Stages only: the stages used by the latest published version of this pipeline, in step order; a pipeline that does not +/// exist or has no published version gives an empty page. rejects it. +/// +public sealed record CatalogQuery( + string? Search = null, int PageNumber = 1, int PageSize = 25, VersionStatus? Status = null, string? UsedByPipeline = null); /// A new stage or pipeline. /// Slug (immutable). diff --git a/src/PythonScriptRunner.Dashboard/Api/StageEndpoints.cs b/src/PythonScriptRunner.Dashboard/Api/StageEndpoints.cs index c8bdd6d..2f2a7c2 100644 --- a/src/PythonScriptRunner.Dashboard/Api/StageEndpoints.cs +++ b/src/PythonScriptRunner.Dashboard/Api/StageEndpoints.cs @@ -52,9 +52,10 @@ public static void Map(RouteGroupBuilder api) } private static async Task> ListAsync( - string? search, VersionStatus? status, int? page, int? pageSize, ICatalogAdmin admin, CancellationToken cancellationToken) => + string? search, VersionStatus? status, string? pipelineKey, int? page, int? pageSize, ICatalogAdmin admin, CancellationToken cancellationToken) => ApiPage.From(await admin.ListStagesAsync( - new CatalogQuery(search, page ?? 1, Math.Clamp(pageSize ?? 25, 1, 100), status), cancellationToken).ConfigureAwait(false)); + new CatalogQuery(search, page ?? 1, Math.Clamp(pageSize ?? 25, 1, 100), status, string.IsNullOrWhiteSpace(pipelineKey) ? null : pipelineKey.Trim()), + cancellationToken).ConfigureAwait(false)); private static async Task CreateAsync(NewCatalogItem stage, HttpContext http, ICatalogAdmin admin, CancellationToken cancellationToken) { diff --git a/src/PythonScriptRunner.EntityFrameworkCore/Catalog/EfCatalogAdmin.Pipelines.cs b/src/PythonScriptRunner.EntityFrameworkCore/Catalog/EfCatalogAdmin.Pipelines.cs index 713ee41..46f56ee 100644 --- a/src/PythonScriptRunner.EntityFrameworkCore/Catalog/EfCatalogAdmin.Pipelines.cs +++ b/src/PythonScriptRunner.EntityFrameworkCore/Catalog/EfCatalogAdmin.Pipelines.cs @@ -10,6 +10,11 @@ internal sealed partial class EfCatalogAdmin public async Task> ListPipelinesAsync(CatalogQuery query, CancellationToken cancellationToken = default) { var (pageNumber, pageSize) = Paging(query); + if (query.UsedByPipeline is not null) + { + throw new ArgumentException("UsedByPipeline filters stages only.", nameof(query)); + } + var db = await factory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (db.ConfigureAwait(false)) { diff --git a/src/PythonScriptRunner.EntityFrameworkCore/Catalog/EfCatalogAdmin.cs b/src/PythonScriptRunner.EntityFrameworkCore/Catalog/EfCatalogAdmin.cs index 7b9dccb..04140cc 100644 --- a/src/PythonScriptRunner.EntityFrameworkCore/Catalog/EfCatalogAdmin.cs +++ b/src/PythonScriptRunner.EntityFrameworkCore/Catalog/EfCatalogAdmin.cs @@ -53,8 +53,20 @@ public async Task> ListStagesAsync(CatalogQuery query, Cancel stages = stages.Where(s => s.Versions.Any(v => v.Status == status)); } + var ordered = stages.OrderBy(s => s.Key); + if (query.UsedByPipeline is { } pipelineKey) + { + // The steps of the latest published version of the pipeline (D-164, D-165); a stage used by two steps shows + // once, at its first step. + var steps = db.PipelineSteps.Where(step => db.PipelineVersions.Any(v => + v.Id == step.PipelineVersionId && v.Status == VersionStatus.Published + && v.Pipeline.Key == pipelineKey && v.Version == v.Pipeline.LatestPublishedVersion)); + stages = stages.Where(s => steps.Any(step => step.StageVersion.StageId == s.Id)); + ordered = stages.OrderBy(s => steps.Where(step => step.StageVersion.StageId == s.Id).Min(step => step.Order)).ThenBy(s => s.Key); + } + var total = await stages.CountAsync(cancellationToken).ConfigureAwait(false); - var items = await stages.OrderBy(s => s.Key) + var items = await ordered .Skip((pageNumber - 1) * pageSize).Take(pageSize) .Select(s => new StageSummary( s.Key, s.Name, s.Description, s.LatestPublishedVersion, diff --git a/tests/PythonScriptRunner.Dashboard.Tests/CatalogApiTests.cs b/tests/PythonScriptRunner.Dashboard.Tests/CatalogApiTests.cs index e3c2c3c..d6fcf0d 100644 --- a/tests/PythonScriptRunner.Dashboard.Tests/CatalogApiTests.cs +++ b/tests/PythonScriptRunner.Dashboard.Tests/CatalogApiTests.cs @@ -95,6 +95,35 @@ public async Task Spec007_AC16_Secrets_ShowStatusButNeverValues() secrets[1]!["declaredBy"]!.AsArray().Select(d => d!.GetValue()).ShouldBe(["envia@1", "outra@1"]); } + [Fact] + public async Task Spec007_AC26_StagesOfAPipeline_AreThoseOfItsLatestPublishedVersion_InStepOrder() + { + await using var env = await PsrEnvironment.CreateAsync(postgres, broker, python); + var host = await env.StartHostAsync(s => s.Worker = false); + await env.ImportFixtureAsync(host, "calculo-progressao"); + await using var app = await DashboardApp.StartAsync(env); + await app.PublishStageAsync("eco", Scripts.Echo, [Scripts.Text("texto")], [Scripts.Text("texto")]); + var draft = await app.Admin.CreatePipelineDraftFromAsync("calculo-progressao", 1, PsrEnvironment.Author, Ct); + var withEco = draft.Data with + { + Steps = [.. draft.Data.Steps, new PipelineStepDefinition("eco", "eco", 1) { Bindings = [new InputBinding("texto", new ConstantSource("x"))] }], + }; + await app.Admin.SavePipelineDraftAsync("calculo-progressao", draft.Version, withEco, draft.RowVersion, PsrEnvironment.Author, Ct); + var onlyDraft = await app.Admin.CreatePipelineAsync(new NewCatalogItem("so-rascunho", "Só rascunho"), PsrEnvironment.Author, Ct); + await app.Admin.SavePipelineDraftAsync("so-rascunho", onlyDraft.Version, withEco, onlyDraft.RowVersion, PsrEnvironment.Author, Ct); + using var reader = await app.LoginAsync("leitor"); + + async Task KeysAsync(string query) => + (await (await reader.GetAsync($"/psr/api/stages?{query}", Ct)).JsonAsync())["items"]!.AsArray() + .Select(s => s!["key"]!.GetValue()).ToArray(); + + (await KeysAsync("pipelineKey=calculo-progressao")).ShouldBe(["progressao-termos", "polinomio-avaliar", "estatisticas", "montar-tabela"]); + (await KeysAsync("pipelineKey=calculo-progressao&search=poli")).ShouldBe(["polinomio-avaliar"]); + (await KeysAsync("pipelineKey=so-rascunho")).ShouldBeEmpty(); + (await KeysAsync("pipelineKey=nao-existe")).ShouldBeEmpty(); + (await KeysAsync(string.Empty)).ShouldContain("eco"); + } + [Fact] public async Task FieldTypes_DescribeTheTwelveTypesAndTheCompatibility() { diff --git a/tests/PythonScriptRunner.Dashboard.Tests/Snapshots/openapi-v1.verified.json b/tests/PythonScriptRunner.Dashboard.Tests/Snapshots/openapi-v1.verified.json index ae04bcd..84b29b5 100644 --- a/tests/PythonScriptRunner.Dashboard.Tests/Snapshots/openapi-v1.verified.json +++ b/tests/PythonScriptRunner.Dashboard.Tests/Snapshots/openapi-v1.verified.json @@ -409,6 +409,13 @@ "$ref": "#/components/schemas/VersionStatus" } }, + { + "name": "pipelineKey", + "in": "query", + "schema": { + "type": "string" + } + }, { "name": "page", "in": "query", diff --git a/tests/PythonScriptRunner.Panel.E2E.Tests/OperationTests.cs b/tests/PythonScriptRunner.Panel.E2E.Tests/OperationTests.cs index 7964396..be1602f 100644 --- a/tests/PythonScriptRunner.Panel.E2E.Tests/OperationTests.cs +++ b/tests/PythonScriptRunner.Panel.E2E.Tests/OperationTests.cs @@ -159,6 +159,41 @@ private async Task PublishSleepAsync() await App.PublishPipelineAsync("demorada", Scripts.SingleStep("dorme", 1, inputs)); } + [Fact] + public async Task Spec008_AC27_AScreenWhoseCodeFailsToDownload_ReloadsOnce_AndThenShowsTheWarning() + { + await StartAsync(s => s.Worker = false); + var page = await OpenAsync(PanelUsers.Reader); + var runListRequests = 0; + await page.RouteAsync("**/assets/RunListView-*.js", async route => + { + if (Interlocked.Increment(ref runListRequests) == 1) + { + await route.AbortAsync(); + } + else + { + await route.ContinueAsync(); + } + }); + + await GoToAsync(page, "execucoes"); + + await Expect(page.GetByTestId("runs-table")).ToBeVisibleAsync(); + runListRequests.ShouldBe(2, "one failed download, then the reload"); + + // Failing again after the reload: the warning, without reloading in a loop. + await page.RouteAsync("**/assets/ScheduleListView-*.js", route => route.AbortAsync()); + var loads = 0; + page.Load += (_, _) => Interlocked.Increment(ref loads); + await GoToAsync(page, "agendamentos"); + + await Expect(page.GetByRole(AriaRole.Heading, new PageGetByRoleOptions { Name = "Não foi possível carregar esta tela" })).ToBeVisibleAsync(); + await Expect(page).ToHaveURLAsync(App.UrlOf("falha-ao-carregar?rota=/agendamentos")); + await Expect(page.GetByTestId("reload")).ToBeVisibleAsync(); + loads.ShouldBe(2, "the navigation and a single reload"); + } + private async Task OpenScheduleFormAsync(IPage page, string pipeline) { await GoToAsync(page, $"trilhas/{pipeline}"); diff --git a/tests/PythonScriptRunner.Panel.E2E.Tests/StageTests.cs b/tests/PythonScriptRunner.Panel.E2E.Tests/StageTests.cs index 61d3557..a1d37e1 100644 --- a/tests/PythonScriptRunner.Panel.E2E.Tests/StageTests.cs +++ b/tests/PythonScriptRunner.Panel.E2E.Tests/StageTests.cs @@ -141,6 +141,37 @@ public async Task Spec008_AC25_StageList_CountsOnlyThePipelinesWithAPublishedVer await Expect(row.GetByTestId("used-by")).ToHaveTextAsync("usada em 1 trilha"); } + [Fact] + public async Task Spec008_AC26_TrilhaFilter_ShowsTheStagesOfThePublishedVersionInStepOrder_AndSurvivesAReload() + { + await StartAsync(s => s.Worker = false); + await App.ImportFixtureAsync("calculo-progressao"); + await App.ImportFixtureAsync("html-para-email"); + await App.PublishStageAsync("eco", PanelScripts.Echo, [Scripts.Text("texto")], [Scripts.Text("texto")]); + var draft = await App.Host.Admin.CreatePipelineDraftFromAsync("calculo-progressao", 1, PsrEnvironment.Author, Ct); + await App.Host.Admin.SavePipelineDraftAsync("calculo-progressao", draft.Version, draft.Data with + { + Steps = [.. draft.Data.Steps, new PipelineStepDefinition("eco", "eco", 1) { Bindings = [new InputBinding("texto", new ConstantSource("x"))] }], + }, draft.RowVersion, PsrEnvironment.Author, Ct); + var page = await OpenAsync(PanelUsers.Reader); + await GoToAsync(page, "etapas"); + var keys = page.GetByTestId("catalog-table").Locator("[data-key]"); + await Expect(keys).ToHaveCountAsync(7); + + await page.Locator(".v-main").ChooseAsync("Trilha", "Cálculo de progressão"); + + string[] calculo = ["progressao-termos", "polinomio-avaliar", "estatisticas", "montar-tabela"]; + await Expect(keys).ToHaveTextAsync(calculo); + await Expect(page).ToHaveURLAsync(App.UrlOf("etapas?trilha=calculo-progressao")); + await page.ReloadAsync(); + await Expect(keys).ToHaveTextAsync(calculo); + + await GoToAsync(page, "trilhas/html-para-email"); + await page.GetByTestId("pipeline-stages").ClickAsync(); + await Expect(page).ToHaveURLAsync(App.UrlOf("etapas?trilha=html-para-email")); + await Expect(keys).ToHaveTextAsync(["html-para-pdf", "enviar-email"]); + } + /// Adds a field (string, required) to the inputs or outputs builder and names it. private static async Task AddFieldAsync(IPage page, string kind, string name) {