Files
TallerCiCd/frontend/src/App.svelte
2025-12-24 13:39:10 +01:00

480 lines
19 KiB
Svelte

<!--
CI/CD Workshop
Copyright (C) 2025 OpenBokeron
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script>
import { onMount } from 'svelte';
import { API_BASE, ENABLE_POLLING } from './config';
import { getBuildHistory, getCiStatus, getMenu, getPrices } from './services/api';
import { prettify } from './utils/text';
import { fly, fade } from 'svelte/transition';
let menu = null;
let prices = [];
let loadingMenu = true;
let loadingPrices = true;
let errorMessage = '';
let ciStatus = null;
let loadingCiStatus = true;
let buildHistory = [];
let loadingHistory = true;
let showFishToast = false;
function onFishClick() {
if (showFishToast) return;
showFishToast = true;
setTimeout(() => {
showFishToast = false;
}, 2800);
}
async function fetchMenu() {
loadingMenu = true;
try {
menu = await getMenu();
} catch (error) {
errorMessage = `Hoy no cafete (good ending): ${error.message}`;
} finally {
loadingMenu = false;
}
}
async function fetchPrices() {
loadingPrices = true;
try {
prices = await getPrices();
} catch (error) {
errorMessage = `No pudimos cargar los precios: ${error.message}`;
} finally {
loadingPrices = false;
}
}
async function fetchCiStatus() {
loadingCiStatus = true;
try {
ciStatus = await getCiStatus();
} catch (error) {
errorMessage = `No se pudo obtener el estado del sistema: ${error.message}`;
} finally {
loadingCiStatus = false;
}
}
async function fetchBuildHistory() {
loadingHistory = true;
try {
const history = await getBuildHistory();
buildHistory = history.builds || [];
} catch (error) {
errorMessage = `No pudimos leer el historial CI: ${error.message}`;
} finally {
loadingHistory = false;
}
}
async function loadInitialData() {
await Promise.all([fetchMenu(), fetchPrices(), fetchCiStatus(), fetchBuildHistory()]);
}
onMount(() => {
loadInitialData();
if (!ENABLE_POLLING) return;
const pricesInterval = setInterval(fetchPrices, 180000);
const ciInterval = setInterval(fetchCiStatus, 300000);
return () => {
clearInterval(pricesInterval);
clearInterval(ciInterval);
};
});
</script>
<main>
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">Taller CI/CD con Jenkins</p>
<h1>UwU</h1>
<p class="lede">
FastAPI + Svelte para pipelines CI/CD. No se nos ha ocurrido nada mejor para el
taller así que hemos hecho un proyectito basado en una cafetería que para nada nada
está inspirada en la de nuestra querida escuela.
</p>
<div class="actions">
<button on:click={() => fetchMenu()}>Refrescar menú</button>
<button on:click={() => fetchPrices()} class="ghost">Recalcular desayunos</button>
</div>
<p class="meta">
Backend: {API_BASE} · Endpoints: /menu · /prices · /prices/:item · /health
</p>
</div>
<div class="price-hero">
<p class="pill">Convenio universitario</p>
{#if menu}
<div class="price-stack">
<span class="old">{menu.university_deal.old_price.toFixed(2)}</span>
<span class="new">{menu.university_deal.current_price.toFixed(2)}</span>
</div>
<p class="tiny">{menu.university_deal.note}</p>
{:else}
<p class="tiny">Esperando el menú...</p>
{/if}
</div>
</section>
{#if errorMessage}
<div class="banner error">
<p>{errorMessage}</p>
</div>
{/if}
<section class="cards">
<article class="card menu-card">
<div class="card-head">
<div>
<p class="label">Menú del día</p>
</div>
{#if loadingMenu}
<span class="tag">cargando...</span>
{/if}
</div>
{#if menu}
<div
class={`menu-layout ${menu?.alternative?.items?.length ? 'with-alternative' : ''}`}
>
<div class="menu-primary">
<div class="menu-grid">
<div>
<p class="section-title">Primeros</p>
<ul>
{#each menu.starters as starter}
<li>{starter}</li>
{/each}
</ul>
</div>
<div>
<p class="section-title">Segundos</p>
<ul>
{#each menu.mains as main}
<li
class:fish={main.toLowerCase().includes('pescado') ||
main.toLowerCase().includes('pedro')}
on:click={() => {
if (
main.toLowerCase().includes('pescado') ||
main.toLowerCase().includes('pedro')
) {
onFishClick();
}
}}
>
{main}
</li>
{#if showFishToast}
<div
class="toast"
in:fly={{ y: 20, duration: 200 }}
out:fade={{ duration: 150 }}
>
<div class="toast-icon">🐟</div>
<div class="toast-content">
<div class="toast-title">Legacy feature</div>
<div class="toast-text">
A día de hoy, lleva habiendo pescado al
vapor (y/o variantes) diariamente desde hace
varios años.
</div>
</div>
</div>
{/if}
{/each}
</ul>
</div>
<div>
<p class="section-title">Guarnición</p>
<ul>
{#each menu.garnish as garnish}
<li>{garnish}</li>
{/each}
</ul>
</div>
<div>
<p class="section-title">Postres</p>
<ul>
{#each menu.desserts as dessert}
<li>{dessert}</li>
{/each}
</ul>
</div>
</div>
<div class="notes">
{#each menu.notes as note}
<span>{note}</span>
{/each}
</div>
</div>
{#if menu.alternative?.items?.length}
<aside class="menu-alternative">
<p class="label alt-label">{menu.alternative.title}</p>
<p class="alt-price">
<span class="alt-price-value"
>{menu.alternative.price?.toFixed(2) ?? '5.00'}</span
>
<span class="alt-price-meta">Pues por si no quieres el menú</span>
</p>
<ul class="alt-items">
{#each menu.alternative.items as alternativeItem}
<li>{alternativeItem}</li>
{/each}
</ul>
<p class="tiny">
Mira, si te estás planteando esto, mejor quédate sin comer.
</p>
</aside>
{/if}
</div>
<div class="deal">
<div>
<p class="caption">Menú convenio</p>
<div class="price-line">
<span class="old">{menu.university_deal.old_price.toFixed(2)}</span>
<span class="new"
>{menu.university_deal.current_price.toFixed(2)}</span
>
</div>
<small class="tiny">{menu.university_deal.note}</small>
</div>
<div class="cta">
<span class="chip">El espeto consejo del día: {menu.espetos_tip}</span>
<span class="chip subtle">
Última actualización {menu.availability.last_updated}
</span>
</div>
</div>
{:else if !loadingMenu}
<p>No pudimos leer el menú.</p>
{/if}
</article>
<article class="card">
<div class="card-head">
<div class="label">Desayunos</div>
{#if loadingPrices}
<span class="tag">cargando...</span>
{/if}
</div>
{#if prices.length}
<div class="price-grid">
{#each prices as price}
<div class="price-card">
<p class="item">{prettify(price.item)}</p>
<p class="value">{price.price}</p>
<p class="timestamp">{price.generated_at}</p>
</div>
{/each}
</div>
<p class="meta">
Dependiendo de si vas por la mañana o por la tarde los precios cambian. No sé,
como no ponen los precios al público... :p
</p>
{:else if !loadingPrices}
<p>No hay precios que mostrar.</p>
{/if}
</article>
<article class="card ci-card">
<div class="card-head">
<div>
<p class="label">Estado del sistema</p>
<p class="sub">
Información de build y backend. Tanto el Build, Commit y Autor lo
recuperamos gracias a Jenkins, inyectando ciertas variables a la hora de
hacer despliegue del backend.
</p>
</div>
{#if loadingCiStatus}
<span class="tag">comprobando...</span>
{/if}
</div>
{#if ciStatus}
<div class="ci-grid">
<div class="ci-item">
<p class="caption">API</p>
<p class="highlight">
{ciStatus.status === 'ok' ? '🟢 Operativa' : '🔴 Caída'}
</p>
</div>
<div class="ci-item">
<p class="caption">Build</p>
<p class="highlight">
#{ciStatus.build}
</p>
</div>
<div class="ci-item">
<p class="caption">Commit</p>
<p class="highlight mono">
{ciStatus.commit?.slice(0, 7)}
</p>
</div>
<div class="ci-item">
<p class="caption">Uptime</p>
<p class="highlight">
{ciStatus.uptime_seconds}
</p>
</div>
</div>
<p class="meta">
Autor: {ciStatus.author}
</p>
{:else if !loadingCiStatus}
<p>No se pudo obtener el estado del sistema.</p>
{/if}
</article>
<article class="card history-card">
<div class="card-head">
<div>
<p class="label">Historial</p>
<p class="sub">
Builds recientes en Jenkins. Esto lo conseguimos gracias a que Jenkins nos
expone una API REST para consultar información de los jobs y builds.
</p>
</div>
{#if loadingHistory}
<span class="tag">actualizando...</span>
{/if}
</div>
{#if buildHistory.length}
<div class="history-list">
{#each buildHistory as build, i}
<details class={`history-item ${build.status}`} open={i === 0}>
<summary>
<div class="summary-left">
<span class={`status-dot ${build.status}`}></span>
<span class="summary-title">#{build.number}</span>
{#if build.commits.length === 0}
<span class="muted">Ejecución manual</span>
{:else}
<span class="summary-commit">
{build.commits[0].commit} · {build.commits[0].author}
</span>
{/if}
</div>
<div class="summary-meta">
<span>
{new Date(build.finished_at).toLocaleDateString()}
</span>
</div>
</summary>
<div class="history-body">
{#if build.commits.length > 0}
<p class="history-row">
<span>Mensaje</span>
<span>{build.commits[0].message}</span>
</p>
{/if}
<p class="history-row">
<span>Duración</span>
<span>{build.duration_seconds} s</span>
</p>
{#if build.status === 'failure'}
<p class="history-message danger">Build fallida</p>
{:else}
<p class="history-message success">Correcto</p>
{/if}
</div>
</details>
{/each}
</div>
{:else if !loadingHistory}
<p>No hay builds registradas aún.</p>
{/if}
</article>
<article class="card openbokeron-card">
<div class="card-head">
<div>
<p class="label">Open Bokeron</p>
<p class="sub">Quiénes estamos detrás del taller</p>
</div>
<a
class="tag link-tag"
href="https://openbokeron.org"
target="_blank"
rel="noreferrer"
>
openbokeron.org
</a>
</div>
<div class="openbokeron-body">
<div class="openbokeron-copy">
<p class="openbokeron-text">
Somos Open Bokeron, la asociación de software libre de la ETSII.
</p>
<p class="openbokeron-text subtle">
Este tinglado lo hemos montado nosotros, así que sí: esta tarjeta se queda
todo el taller
</p>
<ul class="openbokeron-list">
<li>Usamos Linux y te juzgamos severamente si vienes con Windows.</li>
<li>A veces hacemos cosas. #HazCosas dicen por aquí en la ETSII.</li>
</ul>
<div class="openbokeron-actions">
<a
class="button-link subtle"
href="https://openbokeron.org"
target="_blank"
rel="noreferrer"
>
Conócenos
</a>
</div>
</div>
<div class="openbokeron-logo">
<div class="logo-bubble">
<img
src={`${import.meta.env.BASE_URL}/open-bokeron-logo.png`}
alt="Logo de Open Bokeron"
loading="lazy"
/>
</div>
</div>
</div>
</article>
</section>
</main>