Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions panel/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2240,6 +2240,7 @@ export interface operations {
query?: {
search?: string;
status?: components["schemas"]["VersionStatus"];
pipelineKey?: string;
page?: number;
pageSize?: number;
};
Expand Down
71 changes: 67 additions & 4 deletions panel/src/components/common/CatalogList.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { api, apiFetch, ensureOk, unwrap, type Schemas } from '@/api/client'
import PageHeader from '@/components/common/PageHeader.vue'
import { useApiAction, useErrorText } from '@/composables/useApiAction'
Expand All @@ -11,9 +11,10 @@ import { formatDateTime } from '@/utils/format'

type Summary = Schemas['StageSummary'] | Schemas['PipelineSummary']

/** Lista de Etapas ou Trilhas (spec 008): busca, filtro por status, versão publicada, uso; Autor cria e importa. */
/** Lista de Etapas ou Trilhas (spec 008): busca, filtro por status (e por trilha, nas Etapas), versão publicada, uso; Autor cria e importa. */
const props = defineProps<{ kind: 'stages' | 'pipelines' }>()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const session = useSession()
const notify = useNotify()
Expand All @@ -31,6 +32,13 @@ const createError = ref<string | null>(null)
const draft = ref({ key: '', name: '', description: '' })
const importing = ref(false)

// Filtro "Trilha" das Etapas (D-164, D-165): só as Etapas da última versão publicada da trilha, na ordem dos passos.
// Fica na URL (`?trilha=`) para o link poder ser compartilhado.
const queryPipeline = () => (props.kind === 'stages' && typeof route.query.trilha === 'string' && route.query.trilha) || null
const pipelineKey = ref<string | null>(queryPipeline())
const pipelines = ref<Schemas['PipelineSummary'][]>([])
const selectedPipeline = computed(() => pipelines.value.find((p) => p.key === pipelineKey.value) ?? null)

const headers = [
{ title: t('catalog.key'), key: 'key', sortable: false },
{ title: t('catalog.name'), key: 'name', sortable: false },
Expand All @@ -49,7 +57,7 @@ async function load() {
}
const result = await run(() =>
props.kind === 'stages'
? unwrap(api.GET('/stages', { params: { query } }))
? unwrap(api.GET('/stages', { params: { query: { ...query, pipelineKey: pipelineKey.value ?? undefined } } }))
: unwrap(api.GET('/pipelines', { params: { query } })),
)
if (result) {
Expand All @@ -70,6 +78,41 @@ watch(search, () => {
}, 300)
})
watch([status, page, pageSize], () => void load(), { immediate: true })
watch(pipelineKey, (key) => {
void router.replace({ query: { ...route.query, trilha: key ?? undefined } })
if (page.value === 1) {
void load()
} else {
page.value = 1
}
})
watch(
() => route.query.trilha,
() => {
if (queryPipeline() !== pipelineKey.value) {
pipelineKey.value = queryPipeline()
}
},
)

async function loadPipelines() {
const all: Schemas['PipelineSummary'][] = []
for (let number = 1; ; number++) {
const result = await unwrap(api.GET('/pipelines', { params: { query: { page: number, pageSize: 100 } } }))
all.push(...result.items)
if (result.items.length === 0 || all.length >= result.total) {
break
}
}

pipelines.value = all
}

onMounted(() => {
if (props.kind === 'stages') {
void run(loadPipelines)
}
})

const detailRoute = (key: string) => ({ name: props.kind === 'stages' ? 'stage' : 'pipeline', params: { key } })

Expand Down Expand Up @@ -156,7 +199,27 @@ function usedBy(item: Summary) {
hide-details
style="max-width: 220px"
/>
<v-autocomplete
v-if="kind === 'stages'"
v-model="pipelineKey"
:items="pipelines.map((p) => ({ value: p.key, title: p.name }))"
:label="t('catalog.pipeline')"
clearable
hide-details
style="max-width: 320px"
data-testid="pipeline-filter"
/>
</v-card-text>
<v-alert
v-if="selectedPipeline && !selectedPipeline.latestPublishedVersion"
type="info"
variant="tonal"
density="compact"
class="mx-4 mb-2"
data-testid="pipeline-unpublished"
>
{{ t('catalog.pipelineUnpublished') }}
</v-alert>
<v-data-table-server
v-model:page="page"
v-model:items-per-page="pageSize"
Expand Down
7 changes: 7 additions & 0 deletions panel/src/i18n/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export default {
forbidden: { title: 'Sem permissão', text: 'Seu papel não permite abrir esta página.' },
},
notFound: { title: 'Página não encontrada', text: 'O endereço não existe no painel.' },
loadFailed: {
title: 'Não foi possível carregar esta tela',
text: 'A conexão oscilou ou o painel foi atualizado enquanto você navegava.',
reload: 'Recarregar',
},
realtime: { unavailable: 'Atualização em tempo real indisponível; a tela continua atualizando a cada 5 segundos.' },
common: {
cancel: 'Cancelar',
Expand Down Expand Up @@ -199,6 +204,7 @@ export default {
version: 'Versão',
publishedAt: 'Publicada em',
pipeline: 'Trilha',
pipelineUnpublished: 'Esta trilha ainda não tem versão publicada.',
export: 'Exportar',
archive: 'Arquivar',
import: 'Importar zip',
Expand Down Expand Up @@ -268,6 +274,7 @@ export default {
deleteText: 'Excluir a trilha {key}, as versões e os agendamentos? Não dá para desfazer.',
recentRuns: 'Execuções recentes',
allRuns: 'Ver todas as execuções',
stagesLink: 'Ver Etapas desta trilha',
run: {
open: 'Disparar',
title: 'Disparar {name}',
Expand Down
28 changes: 24 additions & 4 deletions panel/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { createRouter, createWebHistory, type RouteLocationNormalized, type Rout
import { basePath, type Role } from '@/api/client'
import { useSession } from '@/stores/session'
import AppShell from '@/components/layout/AppShell.vue'
import LoadFailedView from '@/views/LoadFailedView.vue'
import { safeReturn } from './paths'
import { claimReload, isLoadError, sessionStore } from './reload'

declare module 'vue-router' {
interface RouteMeta {
Expand Down Expand Up @@ -33,6 +36,13 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/views/NoRoleView.vue'),
meta: { public: true, title: 'auth.noRole.title' },
},
{
// Importada direto e pública: tem de abrir mesmo quando a rede não entrega os outros pedaços (AC-27).
path: '/falha-ao-carregar',
name: 'load-failed',
component: LoadFailedView,
meta: { public: true, title: 'loadFailed.title' },
},
{
path: '/',
component: AppShell,
Expand Down Expand Up @@ -125,10 +135,7 @@ export const routes: RouteRecordRaw[] = [
},
]

/** Destino interno seguro para "voltar" depois do login (sem redirecionamento aberto). */
export function safeReturn(value: unknown): string {
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\') ? value : '/'
}
export { safeReturn } from './paths'

/**
* Entrada conforme o modo de acesso (spec 008 §0): login fixo → /entrar; host → loginUrl; filtro ou local negado →
Expand Down Expand Up @@ -175,5 +182,18 @@ export async function guard(to: RouteLocationNormalized) {
export function createAppRouter() {
const router = createRouter({ history: createWebHistory(basePath), routes })
router.beforeEach(guard)
// Tela cujo código não baixou (rede oscilou, versão nova do painel): recarrega uma vez na rota pedida; se falhar de
// novo em até 10 s, mostra o aviso com Recarregar (D-163).
router.onError((error, to) => {
if (!isLoadError(error)) {
return
}

if (claimReload(to.fullPath, Date.now(), sessionStore())) {
window.location.assign(router.resolve(to.fullPath).href)
} else {
void router.replace({ name: 'load-failed', query: { rota: to.fullPath } })
}
})
return router
}
4 changes: 4 additions & 0 deletions panel/src/router/paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** Destino interno seguro para "voltar" depois do login (sem redirecionamento aberto). */
export function safeReturn(value: unknown): string {
return typeof value === 'string' && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\') ? value : '/'
}
40 changes: 40 additions & 0 deletions panel/src/router/reload.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { claimReload, isLoadError } from './reload'

function memory(): Pick<Storage, 'getItem' | 'setItem'> {
const items = new Map<string, string>()
return { getItem: (key) => items.get(key) ?? null, setItem: (key, value) => void items.set(key, value) }
}

describe('Recarga de tela que não carregou', () => {
it('Spec008_AC27 reconhece a falha de baixar um pedaço do painel nos três navegadores', () => {
expect(isLoadError(new TypeError('Failed to fetch dynamically imported module: http://x/psr/assets/RunListView-a1.js'))).toBe(
true,
)
expect(isLoadError(new TypeError('error loading dynamically imported module'))).toBe(true)
expect(isLoadError(new TypeError('Importing a module script failed.'))).toBe(true)
expect(isLoadError(new Error('Unable to preload CSS for /psr/assets/RunListView-a1.css'))).toBe(true)
expect(isLoadError(new Error('Request failed with status 500'))).toBe(false)
})

it('Spec008_AC27 recarrega uma vez por rota e não entra em laço dentro de 10 s', () => {
const storage = memory()

expect(claimReload('/execucoes', 1_000, storage)).toBe(true)
expect(claimReload('/execucoes', 5_000, storage)).toBe(false)
expect(claimReload('/agendamentos', 6_000, storage)).toBe(true)
expect(claimReload('/agendamentos', 16_001, storage)).toBe(true)
})

it('Spec008_AC27 sem onde guardar a marca, não recarrega (melhor o aviso do que um laço)', () => {
const blocked: Pick<Storage, 'getItem' | 'setItem'> = {
getItem: () => null,
setItem: () => {
throw new DOMException('quota', 'QuotaExceededError')
},
}

expect(claimReload('/execucoes', 1_000, null)).toBe(false)
expect(claimReload('/execucoes', 1_000, blocked)).toBe(false)
})
})
40 changes: 40 additions & 0 deletions panel/src/router/reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/** Tela cujo código não baixou: recarrega uma vez e, se falhar de novo, avisa (spec 008, AC-27; D-163). */
const storageKey = 'psr.reload'
const window = 10_000

/** Falha ao baixar um pedaço do painel (import dinâmico ou CSS pré-carregado), nos textos do Chromium, Firefox e Safari. */
export function isLoadError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error)
return /dynamically imported module|Importing a module script failed|Unable to preload CSS/i.test(message)
}

/**
* Decide se recarrega a página para `path`: sim na primeira falha; não se já recarregou para a mesma rota há menos de
* 10 s (sem laço) ou se o navegador não deixa guardar a marca.
*/
export function claimReload(path: string, now: number, storage: Pick<Storage, 'getItem' | 'setItem'> | null): boolean {
if (!storage) {
return false
}

try {
const last = JSON.parse(storage.getItem(storageKey) ?? 'null') as { path: string; at: number } | null
if (last && last.path === path && now - last.at < window) {
return false
}

storage.setItem(storageKey, JSON.stringify({ path, at: now }))
return true
} catch {
return false
}
}

/** O `sessionStorage`, ou `null` quando o navegador o bloqueia. */
export function sessionStore(): Storage | null {
try {
return globalThis.sessionStorage ?? null
} catch {
return null
}
}
25 changes: 25 additions & 0 deletions panel/src/views/LoadFailedView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { safeReturn } from '@/router/paths'

/** Tela que não carregou nem depois de recarregar (spec 008, AC-27). Importada direto, sem depender da rede. */
const { t } = useI18n()
const route = useRoute()
const router = useRouter()

function reload() {
window.location.assign(router.resolve(safeReturn(route.query.rota)).href)
}
</script>

<template>
<v-container class="py-12 text-center">
<v-icon icon="mdi-wifi-alert" size="64" color="warning" aria-hidden="true" />
<h1 class="text-h5 mt-4">{{ t('loadFailed.title') }}</h1>
<p class="text-medium-emphasis mt-2">{{ t('loadFailed.text') }}</p>
<v-btn class="mt-6" color="primary" prepend-icon="mdi-refresh" data-testid="reload" @click="reload">{{
t('loadFailed.reload')
}}</v-btn>
</v-container>
</template>
8 changes: 8 additions & 0 deletions panel/src/views/pipelines/PipelineDetailView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,14 @@ const exportUrl = (v: number) => `${apiBase}/pipelines/${encodeURIComponent(key.
>
{{ t('pipelines.run.open') }}
</v-btn>
<v-btn
variant="text"
prepend-icon="mdi-language-python"
:to="{ name: 'stages', query: { trilha: pipeline.key } }"
data-testid="pipeline-stages"
>
{{ t('pipelines.stagesLink') }}
</v-btn>
<v-btn
v-if="session.hasRole('Author')"
variant="tonal"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,12 @@ Task<ScheduleDetail> SetScheduleEnabledAsync(
/// <param name="PageNumber">1-based page number.</param>
/// <param name="PageSize">Items per page (1-100).</param>
/// <param name="Status">Only items that have a version with this status.</param>
public sealed record CatalogQuery(string? Search = null, int PageNumber = 1, int PageSize = 25, VersionStatus? Status = null);
/// <param name="UsedByPipeline">
/// 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. <see cref="ICatalogAdmin.ListPipelinesAsync"/> rejects it.
/// </param>
public sealed record CatalogQuery(
string? Search = null, int PageNumber = 1, int PageSize = 25, VersionStatus? Status = null, string? UsedByPipeline = null);

/// <summary>A new stage or pipeline.</summary>
/// <param name="Key">Slug (immutable).</param>
Expand Down
5 changes: 3 additions & 2 deletions src/PythonScriptRunner.Dashboard/Api/StageEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,10 @@ public static void Map(RouteGroupBuilder api)
}

private static async Task<ApiPage<StageSummary>> 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<StageSummary>.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<IResult> CreateAsync(NewCatalogItem stage, HttpContext http, ICatalogAdmin admin, CancellationToken cancellationToken)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ internal sealed partial class EfCatalogAdmin
public async Task<Page<PipelineSummary>> 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))
{
Expand Down
Loading
Loading