Back to sh0
sh0

Construir un dashboard de produccion con Svelte 5 en 48 horas

Como construimos el dashboard de produccion de sh0 -- temas oscuro/claro, i18n en 5 idiomas, logs WebSocket en tiempo real y 7 paginas principales -- usando Svelte 5 runes y TailwindCSS 4 en 48 horas.

Thales & Claude | March 30, 2026 11 min sh0
EN/ FR/ ES
sveltesvelte-5dashboardtailwindcsstypescriptuifrontend

Todo PaaS tiene un dashboard. La mayoria parecen haber sido construidos en 2018 y nunca actualizados. Queriamos que el nuestro se sintiera como un producto que alguien realmente disfrutara usar -- modo oscuro, cinco idiomas, logs en tiempo real, responsivo hasta movil -- y queriamos que estuviera embebido en el binario Rust para que sh0 se distribuya como un unico ejecutable sin dependencias externas.

El 12 de marzo de 2026, pasamos de una carpeta dashboard/ vacia a una SPA SvelteKit completamente funcional con 7 paginas, 8 componentes UI compartidos, un sistema i18n cubriendo cinco idiomas y un cliente API tipado que maneja autenticacion, estados de error y reconexion WebSocket. Luego embebimos todo en nuestro binario Rust usando include_dir. Cuarenta y ocho horas despues, cada pagina tenia datos reales.

Asi es como se desarrollaron las Fases 12, 13 y 14 -- el andamiaje, las paginas principales y las paginas extendidas que transformaron sh0 de una herramienta solo CLI en una plataforma con rostro.

Por que Svelte 5 y no React

La decision tomo unos treinta segundos. Necesitabamos un framework que compilara a assets estaticos (sin runtime de servidor), produjera bundles pequenos (el binario no deberia ganar 3 MB de JavaScript) y nos dejara movernos rapido sin boilerplate.

Svelte 5 fue el claro ganador. Las runes ($state, $derived, $effect, $props) eliminaron la ceremonia de stores que React demanda. Sin la danza useState / useEffect / useCallback / useMemo. Sin bugs de arrays de dependencias. Solo declara tu estado y usalo.

SvelteKit 2 con adapter-static nos dio enrutamiento basado en archivos y un paso de build que produce HTML, CSS y JavaScript planos -- exactamente lo que necesitabamos para embeber en un binario Rust.

typescript// svelte.config.js
import adapter from '@sveltejs/adapter-static';

export default {
  kit: {
    adapter: adapter({
      fallback: 'index.html' // Modo SPA: todas las rutas servidas por index.html
    })
  }
};

El fallback: 'index.html' es critico. Como nuestro servidor Rust sirve el dashboard como archivos estaticos, cada ruta necesita resolver al mismo index.html para que el router del lado del cliente de SvelteKit tome el control.

Fase 12: el andamiaje

La Fase 12 se ejecuto en paralelo con la Fase 11 (el motor de respaldos). Dos equipos de agentes, cero superposicion de archivos. Mientras el crate de respaldos se estaba escribiendo en Rust, el dashboard se estaba andamiando en TypeScript.

El andamiaje entrego ocho cosas en una sola sesion:

1. TailwindCSS 4 con temas personalizados. Definimos los colores de marca de sh0 como propiedades CSS personalizadas, luego las referenciamos en la config de Tailwind. Los temas oscuro y claro usan los mismos nombres de clase -- solo cambian los valores de las variables.

css/* app.css */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f8fafc;
  --text-primary: #0f172a;
  --text-secondary: #475569;
  --border: #e2e8f0;
  --accent: #6366f1;
}

.dark {
  --bg-primary: #0f172a;
  --bg-secondary: #1e293b;
  --text-primary: #f1f5f9;
  --text-secondary: #94a3b8;
  --border: #334155;
  --accent: #818cf8;
}

Cada componente usa bg-[var(--bg-primary)] y text-[var(--text-primary)]. Sin clases condicionales. Sin sopa de prefijos dark:. Un sistema, y funciona.

2. El sistema i18n. Cinco idiomas desde el primer dia: ingles, frances, espanol, portugues y kiswahili. Cubriremos el razonamiento en un articulo dedicado, pero la implementacion fue directa: una funcion t() respaldada por objetos TypeScript por locale, con un store de locale persistido en localStorage.

3. El store de auth y flujo de login. Un store Svelte mantiene el token JWT y el perfil del usuario. Cada ruta protegida verifica este store; si esta vacio, redirige a /login. La pagina de login soporta flujos tanto de contrasena como de TOTP.

4. El cliente API. Un unico modulo api.ts que envuelve fetch con inyeccion automatica de token Bearer, parseo JSON y redireccion en 401. Si el servidor devuelve un 401, el cliente limpia el store de auth y envia al usuario de vuelta a login -- sin estados de sesion obsoletos.

typescriptasync function api<T>(method: string, path: string, body?: unknown): Promise<T> {
  const token = get(authStore).token;
  const res = await fetch(`/api/v1${path}`, {
    method,
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {})
    },
    body: body ? JSON.stringify(body) : undefined
  });

  if (res.status === 401) {
    authStore.set({ token: null, user: null });
    goto('/login');
    throw new Error('Unauthorized');
  }

  const json = await res.json();
  return json.data as T;
}

5. Cliente WebSocket con auto-reconexion. Backoff exponencial empezando en 1 segundo, con tope en 30 segundos. El WebSocket se usa para streaming de logs en tiempo real, y sabiamos por experiencia que las conexiones se caen -- especialmente en redes africanas donde la conectividad puede ser intermitente.

6. Ocho componentes UI compartidos. Button, Input, Card, Badge, Modal, Spinner, EmptyState y Toast. Cada uno usa la rune $props() de Svelte 5 y esta completamente tipado.

7. Layout de sidebar responsivo. Una sidebar colapsable con navegacion de icono + texto, un menu hamburguesa movil y un guard de auth que envuelve todo el grupo de rutas (app).

8. Siete paginas placeholder. Dashboard, Apps, Bases de datos, Respaldos, Monitorizacion, Templates y Configuracion -- cada una con solo un encabezado i18n, lista para ser reemplazada con contenido real.

Fase 13: las paginas principales cobran vida

La siguiente sesion reemplazo los placeholders con paginas funcionales. Este fue el sprint donde el dashboard dejo de ser un esqueleto y empezo a ser util.

La vista general del Dashboard

La pagina principal muestra cuatro tarjetas de estadisticas (total de apps, bases de datos, despliegues activos, estado del sistema), una lista de despliegues recientes y botones de accion rapida. Cada numero viene de la API, obtenido al montar con un $effect de Svelte 5.

La lista de Apps

Una cuadricula de tarjetas con busqueda, filtrado y un modal "Crear App". Cada tarjeta muestra el nombre de la app, punto de estado, timestamp del ultimo despliegue y un enlace a la pagina de detalle. La busqueda es del lado del cliente con un debounce de 150ms -- lo suficientemente rapido para sentirse instantaneo, lo suficientemente lento para no dispararse en cada tecla.

El detalle de App: seis pestanas

Esta fue la pagina mas compleja. Una unica ruta (/apps/[id]/+page.svelte) renderiza seis pestanas: Vista general, Despliegues, Logs, Dominios, Entorno y Configuracion. Cada pestana es un componente dedicado.

El sistema de pestanas usa la reactividad de Svelte 5 elegantemente:

svelte<script lang="ts">
  import Tabs from '$lib/components/ui/Tabs.svelte';
  import AppOverview from '$lib/components/app/AppOverview.svelte';
  import LogViewer from '$lib/components/app/LogViewer.svelte';
  // ... otros imports

  let activeTab = $state('overview');
  const tabs = [
    { id: 'overview', label: t('tabs.overview') },
    { id: 'deployments', label: t('tabs.deployments') },
    { id: 'logs', label: t('tabs.logs') },
    { id: 'domains', label: t('tabs.domains') },
    { id: 'env', label: t('tabs.environment') },
    { id: 'settings', label: t('tabs.settings') }
  ];
</script>

<Tabs {tabs} bind:active={activeTab} />

{#if activeTab === 'overview'}
  <AppOverview app={data.app} />
{:else if activeTab === 'logs'}
  <LogViewer appId={data.app.id} />
{/if}

Sin gimnasia de router. Sin complejidad de lazy-loading. Solo renderizado condicional dirigido por una unica variable $state. Svelte 5 hace este patron casi gratuito porque los componentes desmontados son realmente destruidos -- sin fugas de memoria por suscripciones olvidadas.

El LogViewer

El componente LogViewer merece mencion especial. Abre una conexion WebSocket a /api/v1/apps/:id/logs/stream, renderiza las lineas entrantes en un contenedor monoespaciado con auto-scroll, y mantiene un buffer de 1.000 lineas para prevenir crecimiento de memoria. El comportamiento de auto-scroll es cuidadoso: si el usuario ha scrolleado hacia arriba para leer logs anteriores, las nuevas lineas llegan silenciosamente sin mover la vista. Scrollea al fondo, y el auto-scroll se reactiva.

Backend: API de variables de entorno

La Fase 13 tambien anadio tres endpoints Rust para gestion de variables de entorno. Los valores se cifran en reposo usando AES-256-GCM via la misma MasterKey que protege las claves API. El parametro de consulta ?reveal=true dispara el descifrado -- por defecto, los valores se enmascaran con asteriscos. Este es un detalle pequeno, pero importa: mostrar accidentalmente una contrasena de base de datos en una comparticion de pantalla deberia requerir una accion explicita.

La expansion i18n

Cada nueva seccion de UI significo nuevas claves de traduccion. Al final de la Fase 13, el archivo de locale en ingles habia crecido en 9 secciones: dashboard, apps, deployments, domains, logs, env, status, settings y tabs. Los cinco archivos de idioma se actualizaron al unisono.

Fase 14: paginas extendidas

El empujon final reemplazo las cuatro paginas placeholder restantes con implementaciones de produccion.

Pagina de bases de datos. Cuadricula de tarjetas con iconos de motor (PostgreSQL, MySQL, MongoDB, Redis, MariaDB), un modal de creacion con seleccion de motor y sugerencias de version por defecto, busqueda, paginacion y eliminacion. El backend aplico una whitelist de motores -- no puedes crear una instancia "cockroachdb" porque aun no gestionamos el ciclo de vida de ese motor.

Pagina de respaldos. Layout de dos pestanas: Historial y Programaciones. La pestana de historial muestra respaldos pasados con insignias de estado (completado, fallido, en progreso), botones de disparo y restauracion, y eliminacion. La pestana de programaciones permite crear programaciones de respaldo basadas en cron, activarlas/desactivarlas y eliminarlas. La entrada de expresion cron valida en el cliente antes del envio.

Pagina de monitorizacion. Dos pestanas de nuevo: Metricas y Alertas. La pestana de metricas muestra indicadores de CPU y memoria que se auto-refrescan cada 15 segundos. La pestana de alertas proporciona CRUD para reglas de alerta -- disparadores basados en umbrales de uso de CPU, memoria o disco.

Pagina de configuracion. Informacion del servidor, edicion de perfil, configuracion de autenticacion de dos factores TOTP (con codigo QR y codigos de respaldo), y gestion de claves API. El componente TotpSetup.svelte maneja el flujo completo de habilitar/deshabilitar: generar secreto, mostrar codigo QR, verificar un codigo, mostrar codigos de respaldo, confirmar. El componente ApiKeyManager.svelte lista claves activas, crea nuevas (mostrando el secreto exactamente una vez) y elimina las antiguas.

Estrategia de ejecucion

La Fase 14 uso una estrategia de oleadas. La Oleada 1 ejecuto tres agentes en paralelo: handlers backend, tipos/API/i18n del frontend y los sub-componentes de configuracion. La Oleada 2 escribio los cuatro archivos de pagina secuencialmente, ya que cada uno importaba los tipos y modulos API de la Oleada 1. Tiempo total: una sesion. Cero bloqueos.

Embebiendo el dashboard en un binario Rust

Esta es la parte que hace que desplegar sh0 se sienta magico. Todo el dashboard -- cada archivo HTML, cada bundle JavaScript, cada archivo CSS -- se compila en el binario Rust en tiempo de build usando el crate include_dir.

rust// crates/sh0/build.rs
fn main() {
    println!("cargo:rerun-if-changed=../dashboard/build");
}
rust// crates/sh0-api/src/handlers/static_files.rs
use include_dir::{include_dir, Dir};

static DASHBOARD: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../dashboard/build");

pub async fn serve_dashboard(uri: axum::http::Uri) -> impl IntoResponse {
    let path = uri.path().trim_start_matches('/');
    match DASHBOARD.get_file(path) {
        Some(file) => {
            let mime = mime_guess::from_path(path).first_or_octet_stream();
            ([(header::CONTENT_TYPE, mime.as_ref())], file.contents()).into_response()
        }
        None => {
            // Fallback SPA: servir index.html para todas las rutas desconocidas
            let index = DASHBOARD.get_file("index.html").unwrap();
            ([(header::CONTENT_TYPE, "text/html")], index.contents()).into_response()
        }
    }
}

El script build.rs le dice a Cargo que reconstruya cuando la salida del build del dashboard cambie. El handler static_files.rs sirve archivos del directorio embebido, con fallback SPA a index.html para cualquier ruta que no coincida con un archivo real. Esto significa que el router del lado del cliente de SvelteKit funciona perfectamente -- navega a /apps/abc123 y el servidor sirve index.html, que arranca la SPA y renderiza la pagina correcta.

El resultado: sh0 es un unico binario. Descargalo, ejecutalo, abre un navegador. El dashboard ya esta ahi. Sin npm install. Sin servidor frontend separado. Sin configuracion de reverse proxy.

Lo que aprendimos

Las runes de Svelte 5 son un salto genuino. Escribimos 35 archivos fuente (rutas, stores, componentes, i18n, tipos) solo en la Fase 12, y nunca luchamos contra el framework. El modelo $state / $derived / $effect se mapea directamente a como piensas sobre UI: "este valor cambia, este otro valor depende de el, y este efecto secundario deberia ejecutarse cuando cualquiera cambie".

TailwindCSS 4 con propiedades CSS personalizadas es el enfoque correcto de temas. El modo oscuro no es una funcionalidad que acoplas despues -- es una restriccion que da forma a todo tu sistema de diseno. Al usar variables desde el inicio, cada componente que escribimos funciono en ambos temas automaticamente.

i18n desde el primer dia no cuesta casi nada. Escribimos claves de traduccion junto con los componentes. El coste marginal por componente fue quizas 30 segundos. Adaptar i18n a un dashboard existente habria tomado dias.

Embeber una SPA en un binario Rust es sorprendentemente fluido. El crate include_dir, un trigger build.rs y un handler de fallback SPA -- tres piezas pequenas que eliminan toda una preocupacion de despliegue.

En 48 horas, pasamos de un directorio vacio a un dashboard con 11 paginas funcionales, 19 componentes personalizados, 5 idiomas, temas oscuro/claro, streaming de logs en tiempo real y variables de entorno cifradas -- todo compilado en un unico binario Rust. Nada mal para un equipo de dos.


Siguiente en la serie: De listas planas a Stacks: redisenar toda nuestra UX -- como tiramos la lista plana de apps y reconstruimos alrededor de stacks por proyecto con una doble sidebar y secciones estilo cPanel.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles