Merge remote-tracking branch 'origin/main' into fix/1900

This commit is contained in:
melgarafael
2026-09-28 22:39:46 -03:00
5 changed files with 187 additions and 0 deletions
@@ -0,0 +1,11 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: A tela de atualização diz onde está o detalhe da disputa de banco
---
Quando a atualização pelo botão da tela encontra o banco em uso por outro processo e precisa de mais de uma passada, o resumo dessa disputa já aparecia no fim da atualização. Faltava dizer onde procurar o detalhe.
Agora, logo abaixo do resumo, a tela aponta o arquivo `.update.log`, na pasta do projeto no servidor, onde fica o que cada passada não aplicou. A linha aparece tanto quando a atualização termina bem quanto quando ela volta para a versão anterior. Se a rodada não teve disputa, ou se ela não foi medida, a linha não aparece. Nada muda para quem atualiza pela linha de comando. Não exige ação.
Contribuição de @webtecnica (#1040).
+59
View File
@@ -309,6 +309,65 @@ describe("GET /api/v1/system/version", () => {
expect(body.data.run.superseded).toBe(false);
});
/**
* O ESTADO PERSISTIDO DA RODADA CHEGANDO À TELA — o coração do #1040.
*
* O kit grava disputa/retentativas/passada nas três colunas do run; quem
* conta isso para quem clicou é ESTA rota. Se ela devolvesse só
* sucesso/falha (o `status`), a tela continuaria contando uma história mais
* simples que a acontecida — o aviso morreria no `.update.log`, no disco da
* VPS, exatamente como o issue descreve.
*/
it("⭐ devolve a rodada do banco do run — disputa, retentativas e passada", async () => {
versionRow.current_version = "1.0.0";
runRow = {
id: "55555555-5555-4555-8555-555555555555",
status: "success",
last_step: "app",
dispatched_at: new Date().toISOString(),
from_version: "1.0.0",
to_version: "1.1.0",
log_tail: "deadlock detected … 2ª passada fechou",
disputa_de_banco: true,
retentativas_do_banco: 1,
passada_do_banco: 2,
};
vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
const { GET } = await import("../version/route");
const body = await (await GET(get())).json();
expect(body.data.run.status).toBe("success");
expect(body.data.run.rodada_do_banco).toEqual({
disputa: true,
retentativas: 1,
passada: 2,
});
});
it("coluna nula é 'não medido': a rota devolve null, não zero", async () => {
// Rodada que não passou pelo banco (atualização só de código) deixa as três
// colunas nulas. `null` é o que mantém a tela calada; `0` viraria a frase
// "não houve disputa" para uma disputa que ninguém mediu.
versionRow.current_version = "1.0.0";
runRow = {
id: "55555555-5555-4555-8555-555555555555",
status: "success",
last_step: "codigo",
dispatched_at: new Date().toISOString(),
from_version: "1.0.0",
to_version: "1.1.0",
log_tail: "",
disputa_de_banco: null,
retentativas_do_banco: null,
passada_do_banco: null,
};
vi.mocked(loadAuthUser).mockResolvedValue(OWNER as never);
const { GET } = await import("../version/route");
const body = await (await GET(get())).json();
expect(body.data.run.rodada_do_banco).toBeNull();
});
it("depois de um rollback, quem não é dono também vê a versão que está no ar", async () => {
versionRow.current_version = "1.1.0";
runRow = {
@@ -93,3 +93,93 @@ describe("tela de atualização — o botão não fica atrás do changelog", ()
).toBe(true);
});
});
/**
* O #1040: a rodada de atualização sabe que houve disputa de banco, e a tela
* CONTA isso — em português de gente — e diz onde está o detalhe. Até aqui o
* aviso morria só no `.update.log`, no disco da VPS, e quem apertou o botão
* via "sucesso" sem saber que a base estava ocupada.
*/
function rodada(overrides: Partial<NonNullable<SystemVersion["run"]>> = {}): SystemVersion {
return {
current_version: "1.0.0",
is_owner: true,
latest_version: "1.1.0",
update_available: false,
agent_online: true,
just_updated: true,
notes: null,
run: {
id: "55555555-5555-4555-8555-555555555555",
status: "success",
last_step: "app",
from_version: "1.0.0",
to_version: "1.1.0",
log_tail: "",
rodada_do_banco: { disputa: true, retentativas: 1, passada: 2 },
...overrides,
},
};
}
describe("tela de atualização — a rodada conta a disputa do banco", () => {
it("terminou com disputa: a tela mostra o resumo em português de gente", () => {
renderTela(rodada());
// Não é log cru nem código: é frase. Os números vêm do estado persistido.
expect(
screen.getByText(
/O banco estava em disputa com o sistema no ar: foram duas passadas e uma retentativa/,
),
).toBeInTheDocument();
});
it("e diz ONDE está o detalhe — o .update.log, que até aqui era o único lugar que sabia", () => {
renderTela(rodada());
const ponteiro = screen.getByText(/\.update\.log/);
expect(ponteiro.textContent).toContain("na pasta do projeto no servidor");
});
it("o resumo e o ponteiro vêm JUNTOS — apontar o log sem contar o resumo seria jogar o problema pra trás da tela", () => {
renderTela(rodada());
const resumo = screen.getByText(/duas passadas/);
const ponteiro = screen.getByText(/\.update\.log/);
expect(
Boolean(resumo.compareDocumentPosition(ponteiro) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
});
it("rodada medida SEM disputa: conta que foi de uma vez e não aponta log nenhum", () => {
// O controle do issue: aviso sem motivo vira ruído, e quem para de ler o
// ruído para de ler o aviso. Endereço de log é linha só quando há o que ler.
renderTela(
rodada({ rodada_do_banco: { disputa: false, retentativas: 0, passada: 1 } }),
);
expect(screen.getByText(/atualizou de uma vez, na primeira passada/)).toBeInTheDocument();
expect(screen.queryByText(/\.update\.log/)).not.toBeInTheDocument();
});
it("ninguém mediu a rodada: a tela fica calada — nem resumo, nem ponteiro", () => {
renderTela(rodada({ rodada_do_banco: null }));
expect(screen.queryByText(/disputa com o sistema/)).not.toBeInTheDocument();
expect(screen.queryByText(/\.update\.log/)).not.toBeInTheDocument();
});
it("no desfecho em que o servidor voltou atrás também: quem clicou tem o direito de saber", () => {
renderTela({
...rodada({
status: "failed_rolled_back",
rodada_do_banco: { disputa: true, retentativas: 2, passada: 3 },
}),
just_updated: false,
update_available: true,
});
expect(screen.getByText(/foram 3 passadas e 2 retentativas/)).toBeInTheDocument();
expect(screen.getByText(/\.update\.log/)).toBeInTheDocument();
});
});
@@ -164,6 +164,11 @@ export function UpdatePanel() {
// retentativas custou e em qual passada fechou. Sem registro na rodada isto é
// `null`, e a tela fica calada em vez de afirmar zero.
const contaDoBanco = textoDaRodadaDoBanco(data.run?.rodada_do_banco);
// O ponteiro para o `.update.log` vem junto do resumo, mas só quando a
// rodada REGISTROU disputa — é aí que existe passada por passada para ler.
// Rodada medida e limpa fica com o resumo só: endereço de log que ninguém
// precisa abrir é linha a mais para ignorar, e quem ignora uma ignora a próxima.
const detalheNoLog = data.run?.rodada_do_banco?.disputa === true;
if (data.just_updated) {
const pedida = semV(data.run?.to_version);
@@ -183,6 +188,18 @@ export function UpdatePanel() {
{contaDoBanco ? (
<p className="mt-3 text-sm text-muted-foreground">{t(contaDoBanco)}</p>
) : null}
{/* O resumo é a ponta; quem quer ver passada por passada precisa saber
ONDE. Esse endereço até aqui só existia no log do servidor — quem
apertou o botão não tinha como chegar lá sem SSH. Só quando HÁ o que
procurar lá (disputa registrada): ponteiro em rodada limpa é ruído, e
quem para de ler o ruído para de ler o aviso. */}
{detalheNoLog ? (
<p className="mt-3 text-sm text-muted-foreground">
{t(
"O detalhe de cada passada está no arquivo .update.log, na pasta do projeto no servidor.",
)}
</p>
) : null}
</Layout>
);
}
@@ -212,6 +229,13 @@ export function UpdatePanel() {
{contaDoBanco ? (
<p className="mt-3 text-sm text-muted-foreground">{t(contaDoBanco)}</p>
) : null}
{detalheNoLog ? (
<p className="mt-3 text-sm text-muted-foreground">
{t(
"O detalhe de cada passada está no arquivo .update.log, na pasta do projeto no servidor.",
)}
</p>
) : null}
<DetalhesTecnicos texto={data.run.log_tail} />
<Saida
botao={false}
+3
View File
@@ -5908,6 +5908,9 @@ export const DICIONARIO: Traducoes = {
"Detalhes técnicos (útil se for pedir ajuda)": {
es: "Detalles técnicos (útil si vas a pedir ayuda)",
},
"O detalhe de cada passada está no arquivo .update.log, na pasta do projeto no servidor.": {
es: "El detalle de cada pasada está en el archivo .update.log, en la carpeta del proyecto en el servidor.",
},
"Atualização do sistema": { es: "Actualización del sistema" },
"Guardando uma cópia de segurança dos seus dados": {
es: "Guardando una copia de seguridad de tus datos",