Deixando a pipeline visível para acompanhar deploy do blog
Uma das coisas que me aperreia no Computaria é não conseguir acompanhar o deploy pelo próprio blog. Então, já que isso me incomoda, por que não resolver?
O pipeline
Atualmente existem 2 maneiras para saber se o deploy está rodando:
- abrir o repositório na página de jobs/pipelines e ver o último em execução
- abrir no repositório e scrollar pro
README.md
Ambas as soluções não me parecem ótimas. Gostaria de algo mais leve no próprio Computaria.
A ideia
Após uma breve consulta com Kauê resolvi seguir a dica
dele: por no /about
.
No primeiro experimento:
Nah, ficou feio. Já sei que não quero por padrão isso aparecendo. Mas para trazer a informação está suficiente. Preciso apenas esconder o que é feio, e deixar disponível mesmo que feio se pedido explicitamente.
Prova de conceito: trava exceto especificado
Bem, a primeira coisa a se fazer é saber se devemos tomar alguma ação. Para tal, foi definido
como API a presença do query param status
com o valor true
.
Para pegar a URL, usei window.location
.
Dentro do objeto de Location
tem o campo search
, que serve
justamente para manter os query params usados para acessar a URL específica.
Por exemplo, para http://localhost:4000/blog/about?q=1
o valor de window.location.search
é ?q=1
. Para facilitar lidar com o conteúdo de dentro dos query params, tem o objeto
do tipo URLSearchParams
.
Até onde pude perceber da documentação, para instanciar URLSearchParams
, eu preciso da query
string porém sem o ?
do prefixo. Consigo alcançar isso com window.location.search.substring(1)
.
Agora, com esse objeto em mãos, consigo simplesmente consultar o valor de algum query param que eu desejar:
const queryParams = new URLSearchParams(window.location.search.substring(1));
if (queryParams.get("status") === "true") {
console.log("oba, vamos exibir o pipeline!")
} else {
console.log("nops, não vamos exibir nada")
}
Com isso em mãos, preciso tomar a ação de exibir o badge de pipeline. Por uma questão de facilidade,
resolvi colocar como um trecho de HTML incluível:
_includes/pipeline.html
.
Assim, tenho um HTML livre para poder manipular como eu bem entender.
No começo, ele simplesmente era uma <div>
invisível:
<div style="display: none" id="pipeline">
</div>
Para importar, no /about
só precisei colocar
{%include pipeline.html%}
no começo do arquivo, o Jekyll se encarregou
de montar tudo certo.
Ok, vamos por o script para detectar se deveria ou não exibir a tag:
<script>
const queryParams = new URLSearchParams(window.location.search.substring(1));
if (queryParams.get("status") === "true") {
console.log("oba, vamos exibir o pipeline!")
} else {
console.log("nops, não vamos exibir nada")
}
</script>
<div style="display: none" id="pipeline">
</div>
So far, so good. Agora, vamos mudar a exibição para display: block
caso seja para
exibir o pipeline, ou sumir logo de uma vez com a <div>
. Pelo console da web,
bastaria fazer algo nesse esquema:
const pipeline = document.getElementById("pipeline")
if (...) {
pipeline.style.display = "block"
} else {
pipeline.remove()
}
Colocando no fragmento de HTML:
<script>
const queryParams = new URLSearchParams(window.location.search.substring(1));
const pipeline = document.getElementById("pipeline")
if (queryParams.get("status") === "true") {
pipeline.style.display = "block"
} else {
pipeline.remove()
}
</script>
<div style="display: none" id="pipeline">
</div>
E… falhou. Por quê? Porque no momento que a função rodar ainda não tem definido quem é o elemento
com id pipeline
. Então preciso mudar o ciclo de vida para rodar o script apenas quando a página
for carregada. Basta colocar o <script defer>
, certo? Bem, não. Porque defer
não funciona bem
com inline, apenas com arquivo de source explícito.
Veja a documentação.
Ou seja, precisei colocar o arquivo JavaScript explicitamente para o Computaria. Como a priori
tudo que está solto na pasta do blog é colocado como asset disponível para o Jekyll publicar,
criei o js/pipeline-loader.js
:
<script src="{{ "/js/pipeline-loader.js" | prepend: site.baseurl }}" defer>
</script>
<div style="display: none" id="pipeline">
</div>
E no script:
const queryParams = new URLSearchParams(window.location.search.substring(1));
const pipeline = document.getElementById("pipeline")
if (queryParams.get("status") === "true") {
pipeline.style.display = "block"
} else {
pipeline.remove()
}
Ótimo, vamos fazer algo útil e colocar a imagem? Para criar dinamicamente um elemento, só
usar o document.createElement
. Então coloco a URL da badge:
const queryParams = new URLSearchParams(window.location.search.substring(1));
const pipeline = document.getElementById("pipeline")
if (queryParams.get("status") === "true") {
pipeline.style.display = "block"
const pipelineImg = document.createElement("img")
pipelineImg.src = "{{site.repository.base}}/badges/master/pipeline.svg"
pipeline.appendChild(pipelineImg)
} else {
pipeline.remove()
}
Só que mostrou uma imagem quebrada… hmmm, qual a mensagem exibida no console?
GET http://localhost:4000/blog/about/{{site.repository.base}}/badges/master/pipeline.svg [HTTP/1.1 404 Not Found 4ms]
Estranho, ele deveria ter pegue a URL bonitinha do rpositório? Ah, percebi. Ele não processou nada Liquid.
Para lidar com isso resolvi seguir o exemplo em css/main.scss
, um frontmatter vazio.
---
# frontmatter vazio para fazer o parse do liquid
---
const queryParams = new URLSearchParams(window.location.search.substring(1));
const pipeline = document.getElementById("pipeline")
if (queryParams.get("status") === "true") {
pipeline.style.display = "block"
const pipelineImg = document.createElement("img")
pipelineImg.src = "{{site.repository.base}}/badges/master/pipeline.svg"
pipeline.appendChild(pipelineImg)
} else {
pipeline.remove()
}
Isso aparece uma mensagem de erro porque frontmatter não é javascript, e o erro é mostrado
no primeiro const
. Como isso me incomoda, o jeito mais direto que eu pensei para lidar com isso
foi criando um “erro inóquo” mais cedo. Adicionei um ;
logo após o frontmatter:
---
# frontmatter vazio para fazer o parse do liquid
---
;
const queryParams = new URLSearchParams(window.location.search.substring(1));
const pipeline = document.getElementById("pipeline")
if (queryParams.get("status") === "true") {
pipeline.style.display = "block"
const pipelineImg = document.createElement("img")
pipelineImg.src = "{{site.repository.base}}/badges/master/pipeline.svg"
pipeline.appendChild(pipelineImg)
} else {
pipeline.remove()
}
Annoyances…
Ao continuar nos testes, percebi que constantemente aparecia na aba de network um
308
. Mas, por que ele
aparecia? Bem, porque ao fazer a expansão do Liquid acabava com dupla barra antes de
badges
.
Eu obtia originalmente isso:
Com redirecionamento para:
E isso começou a me incomodar conforme eu fazia análises se estava usando cache ou não.
Para resolver isso eu deveria me livrar da dupla barra. Eu poderia simplesmente me livrar
dela não colocar a barra logo depois do valor Liquid sendo expandida, porque afinal
eu poderia saber a priori que a string de {{site.repository.base}}
terminava com /
. Mas, por via das dúvidas, não custa nada realisticamente colocar
aquela barra antes de /badges/master/pipeline.svg
, inclusive é até um indicador
para mim mesmo como leitor.
Mas, já que eu não quero me confiar no conhecimento prévio da existência ou não dessa barra, eu tinha duas opções para isso:
- tratar a nível de expansão Liquid para remover a barra terminal
- tratar a nível de javascript a criação dessa string
O lado JavaScript me pareceu mais fácil. Então só substituir //
por /
, correto?
Hmmm, não. Porque o protocolo aparece antes de ://
, então só fazer essa substituição
grosseira iria resultar na url começar assim: https:/computaria.gitlab.io
. Para
contornar isso então teu faço a seguinte substituição:
const url = "/lalala".replaceAll(/([^:])\/\//, "$1/")
Destrinchando:
- no lugar da substituição, se coloca o que foi encontrado no “primeiro grupo” seguido de uma barra
- a regex combina: qualquer coisa que não
:
(em um grupo), barra, barra
Com essa mudança, https://
não tem match com ([^:])\/\/
, porém todas as outras ocorrências de //
no path tem match perfeito, pois não estarão na frente de um :
. Para ser mais estrito poderia trabalhar
para evitar que o match ocorra em query param/fragmento, mas me pareceu overkill demais.
Prova de conceito: carregamento sem cache
Ok, definido o detalhe de onde situar e mecanismo de trava, precisamos de mecanismo de recarga. Primeira tentativa: simplesmente criar um novo elemento de imagem. Mas, ainda assim, como? O ideal seria “após algum tempo”. Então, isso me dá duas opções, bem dizer:
Ok, bora lá com o que isso faz? setTimeout
recebe um comando que será executado
após um intervalo de tempo E também o dado intervalo. Ele te devolve um ID que você
pode remover usando
clearTimeout
.
Para repetir a chamada, o setTimeout
precisa ser chamado de novo no final.
setInterval
é quase a mesma coisa, só que ele sempre executará o comando
após o intervalo de tempo. O retorno deveria ser um ID que você chamaria
clearInterval
para remover, mas pela documentação funciona com clearTimeout
também
(por via das dúvidas, não confie, use o de semântica correta).
Usando setTimeout
Vamos criar uma chamada em laço com setTimeout
? Que tal imprimir 5
vezes a palavra abóbora
em um campo de texto?
Vou colocar uma textarea
para esse experimento:
<textarea id="garbage-place" disabled="true" cols="80" rows="6" placeholder="(ainda sem valor...)">
</textarea>
Importante o disable="true"
para impedir interação humana direta. Fica pianinho, leitor, vou
te dar botões para interagir aqui.
Bem, hora de adicionar interação a caixa de texto… Vamos definir a função para adicionar
um contador que imprime abóbora
, uma ação para parar esses timeouts, e finalmente um
para limpar a caixa de texto. Pelo menos as ações são claras:
<button onClick="iniciaTimeout()">Inicia timeout</button>
<button onClick="paraTimeout()">Para timeout</button>
<button onClick="clearGarbagePlace()">Limpa o place abaixo</button>
Ok, tenho 3 funções então que eu gostaria que fossem alcaançáveis
pelo HTML. E elas dividem (mesmo que de maneira muito leve) um estado.
Eu sou maníaco por esconder as coisas, então não quero que esse
estado esteja visível fora da tag <script>
.
A minha solução mais óbvia é deixar debaixo de um bloco, assim, ao sair do bloco, as variáveis lá dentro serão invisíveis fora:
{
let aboboraId = null;
}
Tá, mas como deixar as funções visíveis? Bem, experimentando encontrei uma
maneira: function
escapa do escopo. E como variáveis local não exptrapolam
os limites do bloco, eu ainda posso colocar umas funções auxiliares dentro
do bloco de modo que elas não tem significado lá fora. Algo assim:
{
// arrow functions não escapam escopo, porque são variáveis
const sanitizeText = text => text ?? "";
const findGarbagePlace = () => document.getElementById("garbage-place");
const appendGarbagePlace = (text) => {
const gp = findGarbagePlace();
gp.value += sanitizeText(text);
}
let aboboraId = null;
// functions escapam escopo
function iniciaTimeout() {
// yep, por hora isto é fake, hehe
if (aboboraId) {
paraTimeout(aboboraId)
}
clearGarbagePlace();
appendGarbagePlace("abobora\n")
}
function paraTimeout() {
if (aboboraId) {
clearTimeout(aboboraId);
aboboraId = null; // limpando
}
}
function clearGarbagePlace() {
const gp = findGarbagePlace();
gp.value = "";
}
}
Beleza, agora eu preciso lidar com chamadas de timeout. Minha ideia é executar um passo e, ao concluir esse passo, cadastrar o próximo timeout, chamando o mesmo passo. E só para não ficar eterno limitar esse passo a poucas vezes.
Então, se não tivesse a questão do timeout, como seria? Uma chamada recursiva:
function step(cnt) {
if (cnt == 0) {
return;
}
appendGarbagePlace(`abobora ${cnt}\n`) // o cnt é para dar a sensação de avanço
step(cnt - 1)
}
Parece bom, e para adicionar o timeout? Bem, dentro do corpo do passo,
então chamar step
é setar o timeout. Para um bom timeout, preciso do tempo:
function step(cnt, timeout) {
setTimeout(() => {
if (cnt == 0) {
return;
}
appendGarbagePlace(`abobora ${cnt}\n`) // o cnt é para dar a sensação de avanço
step(cnt - 1, timeout)
}, timeout)
}
Ok, só falta guardar o identificador de timeout e estamos prontos. Coloco esse step dentro da função pública exposta e estamos prontos:
function iniciaTimeout() {
if (aboboraId) {
paraTimeout()
}
clearGarbagePlace();
const stepAppendAbobora = (cntDown, timeout) => {
aboboraId = setTimeout(() => {
if (cntDown == 0) {
aboboraId = null; // limpando
return;
}
appendGarbagePlace(`abobora ${cntDown}\n`)
stepAppendAbobora(cntDown - 1, timeout)
}, timeout);
}
stepAppendAbobora(5, 200)
}
Pronto, temos espaço para diversão agora:
Usando setInterval
O uso do setInterval
é bem similar, mas o passo de “chamar novamente” é
implícito. Se eu quero parar o laço, preciso explicitamente cancelar o
setInterval
cadastrado.
Bem, que tal começar igual ao exemplo acima? Só que com o ID da área de rascunho diferente:
<button onClick="iniciaInterval()">Inicia interval</button>
<button onClick="paraInterval()">Para interval</button>
<button onClick="clearGarbagePlace2()">Limpa o place abaixo</button>
<textarea id="garbage-place2" disabled="true" cols="80" rows="6" placeholder="(ainda sem valor...)">
</textarea>
O que vai mudar de fato vai ser a função de iniciaInterval()
, ela
vai diferir bastante da iniciaTimeout()
. Agora eu não posso me confiar
em chamdas recursivas para o step. Então, na função de entrada eu inicializo
uma variável interna que fará parte da clausura passada para setInterval
.
Além disso, quando essa variável chegar em 0, eu removo o registro dela:
function iniciaInterval() {
if (aboboraId) {
paraInterval()
}
clearGarbagePlace2();
let cntDown = 5;
aboboraId = setInterval(() => {
if (cntDown == 0) {
paraInterval();
return
}
appendGarbagePlace(`abobora ${cntDown}\n`)
cntDown -= 1;
}, 200)
}
Tentativas de recarregar
Com o mecanismo de tempo para repetição definido, agora é uma questão de definit como recarregar
a imagem. Primeiramente, analisar os cabeçalhos que o GitLab retorna ao buscar a badge:
https://gitlab.com/computaria/blog//badges/master/pipeline.svg
:
cache-control: private, no-store
cf-cache-status: MISS
expires: Fri, 01 Jan 1990 00:00:00 GMT
strict-transport-security: max-age=31536000
Comparando múltiplas etags de diversas requisições por via das dúvidas:
W/"fb90979d9127d0ecad1fa7bd554426ed"
W/"fb90979d9127d0ecad1fa7bd554426ed"
W/"fb90979d9127d0ecad1fa7bd554426ed"
Bem, a etag foi sempre a mesma, indicando que é o mesmo recurso. O
cache-control: no-store
me indica fortemente que não é para armazenar o cache. O expires
apontando para o passado
indica fortemente que a intenção era indicar que esse recurso não deveria ser considerado
para cache. Até onde se prove o contrário, o cf-cache-status: MISS
apenas indicou que
não bateu no cache da Cloudflare.
Finalmente,
strict-transport-security
.
O que isso quer dizer? O que isso tem a ver com o recurso em si?
Bem, não tem a ver com o recurso sendo acessado. Mas é um indicador de que o site deve ser acessado apenas com HTTPS.
Ok, tudo isso indica que a imagem não deve ser cacheada. Um F5 sempre ocasiona nela sendo baixada novamente, como esperado. Isso para mim é um indicador muito forte de que se eu tiver problema com cache, ele não estará no servidor nem na rede, mas sim alguma coisa a nível de browser-level.
Primeira tentativa: criar um novo elemento img
e jogar o anterior fora.
Para comodidade, nada como ter uma função que retorna o elemento:
const createPipelineImg = () => {
const pipelineImg = document.createElement("img")
pipelineImg.src = ...;
return pipelineImg;
}
E no setTimeout
eu preciso remover os filhos de #pipeline
e inserir
a nova imagem. As opções que achei com ações a partir do pai são:
Bem, removeChild
e replaceChild
envolvem conhecer guardar o elemento antigo para
pedir sua remoção. Já o replaceChildren
não tem drama algum, só passa o novo elemento e bom:
pipeline.replaceChildren(createPipelineImg())
só isso já faz a mágica. Então, como se comporta afinal?
Criar a nova img
não foi suficiente.
Outra alternativa que encontrei foi setar novamente o valor da variável. Com isso,
já não precisa ter a função que gera elementos idênticos, eu só vou “modificar”
a URL que a img
aponta. E, bem, foi assim que eu descobri que um mesmo asset
utilizado em vários lugares da mesma página pode sofrer alguma espécie de caching…
Ok, e se a cada repetição for adicionado um ' '
no final da URL para tentar
enganar o GitLab? Bem, o gitlab percebeu que eu estava tramando…
E se for um queryParam
passado com um argumento o iterador dele?
Mas, a que custo?
Ok, com isso fora de cogitação porque é uma gambiarra, vamos tentar dar um fetch
? E depois
de dar o fetch
pensar em como substituir a imagem?
fetch("{{site.repository.base}}/badges/master/pipeline.svg")
Hmm, erro, de CORS. E como eu não tenho controle sobre o GitLab, o que mais posso fazer?
A tag <img>
não tem reload, mas a tag <iframe>
aparentemente tem…
Ok, novo experimento: criar um /assets/pipeline.html
simplesmente com a tag
img
e apontar pra ele de um iframe. Para a operação de forçar o reload usei
tal qual a resposta do Stack Overflow:
const pipelineIFrame = document.createElement("iframe")
// povoa os valores adequados, enxerta no canto, etc...
// usando src="http://localhost:4000/assets/pipeline-badge.html
// para reload
pipelineIFrame.contentWindow.location.reload();
Para o HTML
E, voi là! Deu certo!
Agora, questões de ajustes para deixar adequado:
- controle de parar/reiniciar o recarregar da badge
- no iframe: seguir as dicas do Editando SVG na mão pra pedir café para lidar com iframe
- dentro do documento: remover margens do
body
para só ter espaço da badge
Fazendo esses ajustes, sai disso
pra isso
Você pode conferir aqui os arquivos usados: