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 @@
+
+
+
+
+
{{ t('loadFailed.title') }}
+
{{ t('loadFailed.text') }}
+ {{
+ t('loadFailed.reload')
+ }}
+
+
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)
{