Files
TallerCiCd/frontend/src/App.svelte
2025-12-15 18:46:35 +01:00

439 lines
18 KiB
Svelte

<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, 8000);
const ciInterval = setInterval(fetchCiStatus, 10000);
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()} class="ghost">Refrescar menú</button>
<button on:click={() => fetchPrices()}>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 ci-card">
<div class="card-head">
<div>
<p class="label">Estado del sistema</p>
<p class="sub">Información de build y 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</p>
</div>
{#if loadingHistory}
<span class="tag">actualizando...</span>
{/if}
</div>
{#if buildHistory.length}
<div class="history-list">
{#each buildHistory as build}
<details
class={`history-item ${build.status}`}
open={build === buildHistory[0]}
>
<summary>
<div class="summary-left">
<span class={`status-dot ${build.status}`}></span>
<span class="summary-title">#{build.number}</span>
<span class="summary-branch">{build.branch}</span>
</div>
<div class="summary-meta">
<span class="mono">#{build.commit}</span>
<span>{build.finished_at}</span>
</div>
</summary>
<div class="history-body">
<p class="history-row">
<span>Autor</span>
<span>{build.author}</span>
</p>
<p class="history-row">
<span>Duración</span>
<span>{build.duration_seconds} s</span>
</p>
{#if build.status === 'failed'}
<p class="history-row">
<span>Stage</span>
<span class="chip danger">{build.failed_stage}</span>
</p>
<p class="history-message">
{build.fun_message}
</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">
<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 openbokeron-card">
<div class="card-head">
<div>
<p class="label">Open Bokeron</p>
<p class="sub">Asociación de software libre</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">
La peña que monta este tinglado. Y como esta es mi web pues hago spam gratis y así ves esta
tarjeta durante todo el taller. De nada.
</p>
<ul class="openbokeron-list">
<li>Usamos Linux y te juzgamos duramente si te vemos con un Windows.</li>
<li>A veces hacemos cosas, #HazCosas dicen aquí en la ETSII.</li>
<li>¡Encuesta! Anda, rellena esta encuesta de satisfacción del taller no me seas: [URL].</li>
</ul>
<div class="openbokeron-actions">
<a
class="button-link"
href="https://openbokeron.org"
target="_blank"
rel="noreferrer"
>
Pásate por la web
</a>
</div>
</div>
<div class="openbokeron-logo">
<div class="logo-bubble">
<img src="/open-bokeron-logo.png" alt="Logo de Open Bokeron" loading="lazy" />
</div>
</div>
</div>
</article>
</section>
</main>