Tres dias despues de construir el dashboard, destruimos la mayor parte.
La UI original -- construida durante las Fases 12 a 14 -- funcionaba. Las apps vivian en una lista plana. Las bases de datos vivian en otra lista plana. Si querias ver que base de datos pertenecia a que app, tenias que recordarlo. Si tenias 20 servicios ejecutandose en 5 proyectos, el dashboard era un muro de tarjetas sin estructura.
Habiamos construido una interfaz funcional. Necesitabamos una con opinion.
El 13 de marzo de 2026, redisenamos todo el dashboard alrededor de un concepto que llamamos stacks: grupos de servicios por proyecto con su propia navegacion, su propia configuracion y su propio modelo mental. Reemplazamos la sidebar de texto de 264 pixeles con una sidebar de iconos de 56 pixeles mas una sidebar contextual de 240 pixeles. Redisenamos la pagina de detalle de stack en secciones estilo cPanel. Y anadimos una paleta de comandos.
Esta es la historia de ese rediseno -- seis fases completadas en un solo dia.
El problema de las listas planas
Las listas planas funcionan cuando tienes tres apps. Se rompen cuando tienes quince.
Considera una configuracion auto-hospedada tipica: un producto SaaS con un frontend Next.js, un backend FastAPI, una base de datos PostgreSQL y un cache Redis. Son cuatro servicios para un proyecto. Anade un entorno de staging y tienes ocho. Anade un segundo producto y estas en doce a dieciseis servicios, todos mezclados en una unica lista ordenada alfabeticamente.
Todos los PaaS competidores tienen este problema. Coolify agrupa servicios vagamente, pero la UI no lo impone. Easypanel tiene proyectos, pero la navegacion aun se siente plana. El modelo de proyecto/servicio de Railway es lo mas cercano a lo que queriamos, pero su UI esta optimizada para hospedaje en la nube, no auto-hospedado.
Queriamos tres cosas:
- Agrupacion. Cada servicio pertenece a exactamente un stack. Un stack es un proyecto -- tu producto SaaS, tu blog, tus herramientas internas.
- Contexto. Cuando trabajas dentro de un stack, la sidebar muestra los servicios de ese stack, no todo en el servidor.
- Secciones. Dentro de un stack, los servicios se categorizan por rol: frontend, backend, base de datos, cache, almacenamiento, cron, dominios, monitorizacion.
La doble sidebar
El cambio mas visible fue la sidebar. La sidebar antigua tenia 264 pixeles de ancho, mostraba etiquetas de navegacion como texto e incluia un selector de idioma. Estaba bien para un prototipo. Era derrochadora para una herramienta de produccion donde el espacio horizontal importa.
La reemplazamos con un layout de doble sidebar:
Sidebar izquierda: 56 pixeles. Navegacion solo con iconos con tooltips al pasar. Inicio, Stacks, Deploy, Respaldos, Monitorizacion, Configuracion, Como funciona. Posicion fija, siempre visible en escritorio, hamburguesa en movil.
Sidebar derecha: 240 pixeles. Contextual -- solo aparece cuando estas dentro de un stack. Muestra un dropdown selector de stack en la parte superior, luego una lista categorizada de servicios en ese stack: apps con puntos de estado, bases de datos con insignias de motor, una seccion de almacenamiento, y un enlace "Anadir Servicio" al Deploy Hub.
svelte<!-- ContextSidebar.svelte -->
<script lang="ts">
let { stackId, apps, databases } = $props<{
stackId: string;
apps: App[];
databases: Database[];
}>();
</script>
<aside class="w-60 border-r border-[var(--border)] bg-[var(--bg-secondary)]
h-full overflow-y-auto">
<StackSelector currentId={stackId} />
<nav class="p-3 space-y-4">
<ServiceList title="Apps" services={apps} type="app" {stackId} />
<ServiceList title="Databases" services={databases} type="db" {stackId} />
<a href="/deploy?stack={stackId}"
class="flex items-center gap-2 text-sm text-[var(--accent)]">
<Plus size={16} /> Anadir Servicio
</a>
</nav>
</aside>El cambio de layout fue simple en CSS pero importante en la sensacion. El area de contenido principal paso de lg:ml-64 a lg:ml-14 cuando esta fuera de un stack, y lg:ml-14 lg:pl-60 cuando esta dentro de uno. La sidebar contextual es parte de la ruta de layout del stack, por lo que aparece y desaparece al navegar -- sin toggle manual, sin gestion de estado.
Rutas y navegacion de stacks
La estructura de enrutamiento refleja el modelo mental. Cada stack tiene su propio espacio de URLs:
/ # Inicio: cuadricula de stacks + servicios no asignados
/stacks/[id] # Vista general de stack: secciones estilo cPanel
/stacks/[id]/services/[sid] # Detalle de app (reutiliza todos los componentes existentes)
/stacks/[id]/databases/[did]# Detalle de base de datos
/stacks/[id]/settings # Configuracion de stack (nombre, color, miembros, eliminar)La decision arquitectonica clave fue la reutilizacion. La pagina de detalle de app en /stacks/[id]/services/[sid] renderiza exactamente los mismos componentes AppOverview, LogViewer, EnvEditor, DomainManager y DeploymentList que construimos en las Fases 13 y 14. No reescribimos un solo componente de app. La ruta de stack simplemente los envuelve en un layout diferente con la sidebar contextual.
svelte<!-- stacks/[id]/+layout.svelte -->
<script lang="ts">
import ContextSidebar from '$lib/components/layout/ContextSidebar.svelte';
let { data, children } = $props();
</script>
<div class="flex h-full">
<ContextSidebar
stackId={data.stackId}
apps={data.apps}
databases={data.databases}
/>
<main class="flex-1 overflow-y-auto p-6">
{@render children()}
</main>
</div>Esto significo que el rediseno fue aditivo, no destructivo. Anadimos nuevas rutas y nuevos componentes de layout. Los componentes de pagina existentes encajaron sin cambios.
Los cambios de backend
El rediseno de stacks requirio dos nuevos endpoints de API:
GET /projects/:id/apps-- listar todas las apps pertenecientes a un proyectoGET /projects/:id/databases-- listar todas las bases de datos pertenecientes a un proyecto
Ambos handlers siguieron los patrones existentes en apps.rs y databases.rs: paginacion, filtrado por project_id y los mismos DTOs de respuesta. El cliente API del frontend gano un modulo stacksApi con metodos listApps() y listDatabases().
El backend ya tenia una tabla projects (creada en una fase anterior para agrupacion organizacional), por lo que no necesitamos cambios de esquema. El concepto de stack es simplemente un proyecto con semantica de frontend superpuesta.
La pagina de inicio: de estadisticas a stacks
La pagina de inicio antigua mostraba cuatro tarjetas de estadisticas y una lista de despliegues recientes. Funcional, pero respondia la pregunta equivocada. Los usuarios no abren un dashboard PaaS preguntandose "cuantas apps tengo?" Lo abren para trabajar en un proyecto especifico.
La nueva pagina de inicio es una cuadricula de stacks. Cada stack es una tarjeta que muestra:
- Un punto de color (elegido por el usuario, para diferenciacion visual)
- El nombre y descripcion del stack
- Insignias de conteo de servicios (apps, bases de datos)
- Puntos de estado para cada servicio en ejecucion
- Un enlace directo a la vista general del stack
Debajo de la cuadricula, una seccion "Servicios no asignados" captura cualquier app o base de datos que fue creada antes de que el sistema de stacks existiera. Un modal "Crear Stack" te permite nombrar, describir y elegir color para un nuevo stack.
svelte<!-- StackCard.svelte -->
<script lang="ts">
let { stack } = $props<{ stack: Stack }>();
let serviceCount = $derived(stack.apps.length + stack.databases.length);
</script>
<a href="/stacks/{stack.id}"
class="block p-4 rounded-lg border border-[var(--border)]
bg-[var(--bg-primary)] hover:shadow-md transition-shadow">
<div class="flex items-center gap-3 mb-2">
<span class="w-3 h-3 rounded-full" style="background: {stack.color}" />
<h3 class="font-semibold text-[var(--text-primary)]">{stack.name}</h3>
<span class="ml-auto text-xs text-[var(--text-secondary)]">
{serviceCount} servicios
</span>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2">
{stack.description || 'Sin descripcion'}
</p>
</a>Secciones de stack estilo cPanel
La pagina de detalle de stack fue donde el rediseno se volvio con opinion. En lugar de listar servicios en una cuadricula plana, los dividimos en ocho secciones inspiradas en el modelo de categorias de cPanel:
| Seccion | Contiene | Ejemplo |
|---|---|---|
| Frontend | Sitios estaticos, SPAs, apps SSR | Next.js, SvelteKit, Vite |
| Backend | Servidores API, workers | FastAPI, Express, servicios Go |
| Base de datos | Almacenes relacionales y de documentos | PostgreSQL, MySQL, MongoDB |
| Cache | Almacenes en memoria | Redis, Memcached |
| Almacenamiento | Almacenamiento de objetos, volumenes | MinIO, volumenes montados |
| Cron | Tareas programadas | Programaciones de respaldo, trabajos de limpieza |
| Dominios | Gestion de dominios | Dominios personalizados, certificados SSL |
| Monitorizacion | Metricas y alertas | Indicadores CPU/memoria, reglas de alerta |
Cada seccion es una tarjeta con un icono gradiente, una insignia de conteo y una lista de hasta tres servicios con puntos de estado. Si una seccion esta vacia, muestra un borde punteado, una descripcion de lo que pertenece ahi y un boton "Anadir" que enlaza al Deploy Hub pre-filtrado a la categoria relevante.
svelte{#each sections as section}
{#if section.services.length > 0}
<div class="rounded-lg border border-[var(--border)] shadow-sm p-4">
<div class="flex items-center gap-3 mb-3">
<div class="p-2 rounded-lg bg-gradient-to-br {section.gradient}">
<svelte:component this={section.icon} size={20} class="text-white" />
</div>
<h3 class="font-medium">{t(section.labelKey)}</h3>
<Badge>{section.services.length}</Badge>
</div>
{#each section.services.slice(0, 3) as svc}
<a href="/stacks/{stackId}/services/{svc.id}" class="flex items-center gap-2 py-1">
<StatusDot status={svc.status} />
<span class="text-sm">{svc.name}</span>
</a>
{/each}
</div>
{:else}
<div class="rounded-lg border-2 border-dashed border-[var(--border)] p-4 opacity-60">
<p class="text-sm text-[var(--text-secondary)]">{t(section.emptyKey)}</p>
<a href="/deploy?stack={stackId}&category={section.category}"
class="text-sm text-[var(--accent)]">
+ {t('stacks.addService')}
</a>
</div>
{/if}
{/each}La logica de clasificacion fue extraida en un modulo compartido stack-sections.ts. Categoriza servicios basandose en su stack tecnologico, motor o etiquetas -- una app Next.js va a Frontend, un servicio FastAPI va a Backend, una instancia Redis va a Cache. Esto centralizo la logica para que tanto la pagina de vista general del stack como la sidebar contextual pudieran usarla.
El modal Anadir Servicio (y su muerte)
La Fase 5 del rediseno creo un AddServiceModal.svelte con tres pestanas: Servicios (seleccion rapida de bases de datos), Templates y Despliegue personalizado. Funcionaba, pero era la abstraccion equivocada.
Para la Fase 8, cuando construimos el Deploy Hub (cubierto en el siguiente articulo), el modal se volvio redundante. El Deploy Hub hace todo lo que el modal hacia, pero mejor -- con 183 opciones, busqueda, categorias y componentes de formulario. Asi que eliminamos el modal completamente y reemplazamos el boton "Anadir Servicio" en la sidebar contextual con un simple enlace: <a href="/deploy?stack={stackId}">. El parametro URL ?stack= pre-selecciona el stack en el Deploy Hub, por lo que el flujo es fluido: haz clic en "Anadir Servicio" en un stack, aterriza en el Deploy Hub con ese stack ya seleccionado, elige lo que quieres desplegar.
Este es un patron al que volvimos repetidamente: construir la solucion rapida, luego reemplazarla con la adecuada y eliminar el andamiaje. La disposicion a eliminar codigo funcional es lo que mantiene un codebase limpio.
La paleta de comandos
Anadimos una paleta de comandos Cmd+K (o Ctrl+K en Linux) para navegacion dirigida por teclado. Escribe un nombre de stack, un nombre de app o una pagina como "settings" y salta directamente ahi. La implementacion es un modal con un input de texto, busqueda difusa entre stacks y servicios, y navegacion con flechas con Enter para seleccionar.
Esta fue una funcionalidad pequena en terminos de codigo pero grande en terminos de la experiencia de usuario que estabamos senalando: sh0 es una herramienta para desarrolladores, y las herramientas para desarrolladores deben respetar los flujos de trabajo con teclado.
Responsividad movil
La doble sidebar planteo un desafio movil obvio. En pantallas mas estrechas que lg (1024px), la sidebar de iconos se colapsa detras de un menu hamburguesa, y la sidebar contextual se convierte en un drawer deslizante activado por un deslizamiento o toque de boton.
El drawer deslizante usa una transicion de Svelte:
svelte{#if sidebarOpen}
<div class="fixed inset-0 z-40 bg-black/50" onclick={() => sidebarOpen = false} />
<aside transition:fly={{ x: -240, duration: 200 }}
class="fixed left-0 top-0 z-50 w-60 h-full
bg-[var(--bg-secondary)] border-r border-[var(--border)]">
<ContextSidebar {stackId} {apps} {databases} />
</aside>
{/if}En movil, la sidebar contextual es una superposicion a pantalla completa con un fondo semi-transparente. Toca el fondo para cerrar. El contenido principal nunca se redimensiona -- solo la superposicion aparece y desaparece.
Eliminando rutas legadas
Despues del rediseno, las rutas antiguas /apps, /databases y /templates eran codigo muerto. Aun funcionaban, pero representaban el modelo de lista plana que habiamos abandonado. Consideramos mantenerlas como vistas alternativas, pero eso significaria mantener dos modelos de navegacion, dos conjuntos de estados de UI y dos modelos mentales para el usuario.
Las eliminamos. El modelo de stacks es el unico modelo. Esta fue una decision de producto deliberada: preferiamos tener un gran paradigma de navegacion que dos mediocres.
El coste de i18n
Cada nuevo componente significo nuevas claves de traduccion. El rediseno de stacks anadio una seccion stacks completa a los cinco archivos de locale, mas nav.home, nav.stacks y nav.domains. Tambien anadimos secciones welcome, how_it_works y stack_sections durante el trabajo de onboarding que acompano al rediseno.
El total fue aproximadamente 65 nuevas claves por idioma. Porque habiamos construido el sistema i18n en la Fase 12 y mantenido la disciplina de anadir traducciones junto a los componentes, esto fue una operacion de copiar-pegar-traducir, no una adaptacion retroactiva.
Lo que impulso el rediseno
Mirando hacia atras, el rediseno era inevitable. Construimos la UI de lista plana primero porque era el camino mas rapido a un dashboard funcional. Pero en el momento en que empezamos a usar el dashboard para gestionar servicios reales, las limitaciones del modelo plano eran obvias.
El modelo de stacks no es original -- se inspira en los proyectos de Railway, los equipos de Vercel y las categorias de cPanel. Lo que hicimos diferente fue combinar los tres: alcance por proyecto (Railway), una sidebar de iconos compacta (Vercel) y secciones por categoria (cPanel). El resultado es un dashboard que escala desde "tengo un sitio WordPress" hasta "ejecuto 50 microservicios en 8 proyectos" sin cambiar el paradigma de navegacion.
Todo el rediseno -- seis fases, 11 archivos nuevos, 13 archivos modificados, adiciones de API backend, actualizaciones i18n en cinco idiomas -- se completo en un solo dia. No porque nos apuraramos. Porque el modelo de componentes de las Fases 12-14 era lo suficientemente solido para que el nuevo layout pudiera reutilizar todo.
Esa es la leccion real: si tus componentes estan bien abstraidos, un rediseno de UI es un ejercicio de layout, no una reescritura.
Siguiente en la serie: El Deploy Hub: 183 opciones, una pagina -- como construimos una experiencia de despliegue estilo Softaculous con 183 opciones, 7 componentes de formulario y una UX de panel dividido.