Página completa y navegación
Crear una página completa (cabecera, KPIs, gráficos, tabla) y una entrada de navegación con insignia, auth y reglas de seguridad.
En esta página
Una extensión puede tener una página completa (un mount PAGE con cabecera, indicadores, gráficos y tablas) y una entrada de navegación (navEntries) que la enlaza desde la barra lateral del correo o desde la consola de administración. Esta guía construye una de punta a punta: un panel de métricas solo para administradores.
Qué existe y qué no
Hay entradas en la barra del correo y en el menú de la consola de administración (con su búsqueda global Ctrl/Cmd+K). No hay paleta de comandos en la aplicación principal: fuera de la consola, una entrada solo aparece en la barra lateral.
Paso a paso#
- Declara las capacidades en
requires.capabilities:ui.pages.v1(componentes de página completa y laTABLEavanzada),nav.entries.v1(navEntries) y, si la página o la ruta usanauth/minLevel,ext.pages.auth.v1yext.routes.auth.v1(másext.routes.v1parabackendRoutes). El validador te dice cuál falta. - Crea el mount `PAGE` con un
path(letras, números,_y-; hasta 4 segmentos) y suauth/minLevel. Su contenido es una UI declarativa normal:PAGE_HEADER,KPI_CARD,CHARTyTABLE. - Carga los datos con
onLoad+CALL_BACKEND(resultKey). Leestate.$loading.<función>ystate.$error.<función>para los estados de carga y error, y reutiliza la misma acción enonRetry. - (Opcional) Añade la ruta de la insignia en
backendRoutes: una rutaGETconauthsessionoadminque devuelve un número. - Añade `navEntries` con
target: "page:<path>". La entrada heredaauth/minLevelde la página y el validador rechaza una entrada menos restrictiva. - Valida y prueba:
npm run validateenbloomx-extensionsy el playground (acepta el manifest completo y muestra las capacidades que exige).
El manifest completo#
{
"manifestVersion": "1.0",
"id": "my-metrics",
"version": "1.0.0",
"name": "Métricas",
"description": "Panel de métricas del dominio con entrada en la consola de administración.",
"icon": "lucide:ChartColumn",
"requires": {
"clientApi": 2,
"capabilities": [
"ui.kit.v2",
"ui.pages.v1",
"nav.entries.v1",
"ext.routes.v1",
"ext.routes.auth.v1",
"ext.pages.auth.v1"
]
},
"permissions": [],
"api": {
"runtime": "nodejs",
"entry": "server.js",
"functions": {
"getStats": {
"handler": "getStats",
"timeout": 10000
},
"getBadge": {
"handler": "getBadge",
"timeout": 5000
}
}
},
"backendRoutes": [
{
"path": "/badge",
"handler": "getBadge",
"method": "GET",
"auth": "admin",
"minLevel": 2
}
],
"state": {
"stats": null
},
"mounts": [
{
"point": "PAGE",
"path": "metrics",
"auth": "admin",
"minLevel": 2,
"component": {
"type": "STACK",
"props": {
"gap": 4
},
"onLoad": {
"action": "CALL_BACKEND",
"function": "getStats",
"resultKey": "stats"
},
"children": [
{
"type": "PAGE_HEADER",
"props": {
"title": "Métricas del dominio",
"description": "Actividad de los últimos 7 días",
"icon": "ChartColumn",
"breadcrumbs": [
{
"label": "Administración",
"url": "/admin"
},
{
"label": "Métricas"
}
],
"loading": "${state.$loading.getStats}",
"error": "${state.$error.getStats}",
"onRetry": {
"action": "CALL_BACKEND",
"function": "getStats",
"resultKey": "stats"
},
"actions": [
{
"type": "BUTTON",
"props": {
"label": "Actualizar",
"icon": "RefreshCw",
"variant": "outline",
"onClick": {
"action": "CALL_BACKEND",
"function": "getStats",
"resultKey": "stats"
}
}
}
]
}
},
{
"type": "GRID",
"props": {
"columns": 3,
"gap": 3
},
"children": [
{
"type": "KPI_CARD",
"props": {
"label": "Recibidos",
"value": "${state.stats.received}",
"delta": "${state.stats.receivedDelta}",
"trend": "up",
"sparkline": "${state.stats.receivedSeries}",
"loading": "${state.$loading.getStats}"
}
},
{
"type": "KPI_CARD",
"props": {
"label": "Spam",
"value": "${state.stats.spam}",
"delta": "${state.stats.spamDelta}",
"trend": "down",
"invertTrend": true,
"loading": "${state.$loading.getStats}"
}
},
{
"type": "KPI_CARD",
"props": {
"label": "Usuarios activos",
"value": "${state.stats.active}",
"icon": "Users",
"loading": "${state.$loading.getStats}"
}
}
]
},
{
"type": "CHART",
"props": {
"kind": "line",
"title": "Correo por día",
"labels": "${state.stats.days}",
"series": [
{
"label": "Recibidos",
"data": "${state.stats.receivedSeries}"
},
{
"label": "Enviados",
"data": "${state.stats.sentSeries}",
"tone": "success"
}
]
}
},
{
"type": "TABLE",
"props": {
"caption": "Mayores remitentes",
"rowKey": "email",
"searchable": true,
"pageSize": 10,
"defaultSort": {
"key": "count",
"dir": "desc"
},
"data": "${state.stats.top}",
"error": "${state.$error.getStats}",
"onRetry": {
"action": "CALL_BACKEND",
"function": "getStats",
"resultKey": "stats"
},
"columns": [
{
"key": "email",
"label": "Remitente",
"sortable": true
},
{
"key": "count",
"label": "Mensajes",
"sortable": true
},
{
"key": "verdict",
"label": "Veredicto",
"format": "status",
"toneMap": {
"ok": "success",
"spam": "danger"
}
}
]
}
}
]
}
}
],
"navEntries": [
{
"id": "metrics",
"section": "admin",
"label": {
"es": "Métricas",
"en": "Metrics"
},
"icon": "ChartColumn",
"order": 50,
"target": "page:metrics",
"minLevel": 2,
"badge": {
"route": "/badge",
"refreshSeconds": 120
}
}
]
}// server.js — sandbox del backend: sin require/process. Cada funcion recibe UN parametro ctx.
async function getStats(ctx) {
// Aquí iría tu consulta real (ctx.services...). Devuelve solo datos JSON.
return {
received: 1284, receivedDelta: '+8 %', spam: 37, spamDelta: '-12 %', active: 54,
days: ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'],
receivedSeries: [210, 190, 240, 220, 260, 80, 84], sentSeries: [90, 85, 110, 95, 120, 30, 28],
top: [{ email: 'ana@example.com', count: 42, verdict: 'ok' }, { email: 'promo@example.net', count: 31, verdict: 'spam' }],
};
}
// La insignia: un número (o { count }). Se vuelve a pedir cada refreshSeconds.
async function getBadge(ctx) {
const stats = await getStats(ctx);
return { count: stats.spam };
}
module.exports = { getStats, getBadge };Este manifest se valida en las pruebas con el mismo validador que usa la aplicación. Los ejemplos reales siguen el mismo patrón y están en bloomx-extensions.
Ejemplos reales del repositorio#
core-domain-metrics(domain-metrics/): panel de administración de solo lectura en/admin/x/metrics(también/extensions/metrics). Páginametricsconauth: "admin"yminLevel: 1; entrada en la secciónadmincon insignia (/badge). Lee datos conservices.stats, que exige el permisoREAD_STATSy un usuario administrador.core-quick-notes(quick-notes/): CRUD de notas en/extensions/notes. Páginanotescon sesión normal; entrada enworkspace, sin insignia. Tope de 50 notas por usuario.- Cada uno tiene
manifest.src.mjs(fuente con los helpers del SDK),manifest.json(generado) yserver.js. Fragmentos exactos de losmanifest.src.mjs:
requires: {
clientApi: 8,
capabilities: [
'ext.pages.auth.v1',
'ext.routes.auth.v1',
'ext.routes.v1',
'nav.entries.v1',
'services.host.v1',
'ui.kit.v2',
'ui.pages.v1',
],
}, permissions: ['READ_STATS'], { path: '/badge', handler: 'getBadge', method: 'GET', auth: 'admin', minLevel: 1, timeoutMs: 10000 }, loading,
error: failed,
onRetry: load(), searchable: true,
searchPlaceholder: t('Buscar fecha (AAAA-MM-DD)', 'Search date (YYYY-MM-DD)'),
defaultSort: { key: 'date', dir: 'desc' }, navEntries: [
{
id: 'domain-metrics',
section: 'admin',
label: t('Métricas del dominio', 'Domain metrics'),
icon: 'lucide:ChartColumn',
order: 40,
target: 'page:metrics',
minLevel: 1,
badge: { route: '/badge', refreshSeconds: 120 },
},
], navEntries: [
{
id: 'quick-notes',
section: 'workspace',
label: t('Notas rápidas', 'Quick notes'),
icon: 'lucide:NotebookPen',
order: 60,
target: 'page:notes',
},
],Secciones#
| Sección | Dónde aparece | Quién la ve |
|---|---|---|
main | Barra lateral del correo, junto a las carpetas. | Cualquier sesión |
workspace | Barra lateral del correo, en el espacio de trabajo. | Cualquier sesión |
tools | Barra lateral del correo, sección nueva «Herramientas». | Cualquier sesión |
admin | Menú «Extensiones» de la consola de administración y su búsqueda global (Ctrl/Cmd+K). | Solo auth: "admin" (y minLevel si lo declaras) |
En main y workspace las entradas se añaden a las carpetas y al espacio de trabajo, y tools crea la sección «Herramientas». Se respetan las preferencias de orden y plegado del usuario. Dentro de una sección, el orden es order (menor primero, 0-1000, por defecto 100).
Campos de una entrada#
| Campo | Descripción |
|---|---|
id | Único dentro de la extensión: [a-z0-9-], máximo 40. |
section | main workspace tools admin |
label | Texto o { "es": ..., "en": ... }, de 1 a 40 caracteres, sin HTML ni saltos de línea. |
icon | Nombre Lucide, lucide:<Nombre>, brand:<slug> o initials:<XY>. |
order | Entero 0-1000. |
target | page:<path> (o /extensions/<path>): el path de un mount PAGE de la propia extensión. No admite URLs externas ni páginas públicas (auth: "none"). |
badge | { route, refreshSeconds }: ver más abajo. |
auth / minLevel | Heredados de la página; solo pueden igualarla o endurecerla. |
mobile | false la oculta en el menú móvil. |
Insignia numérica#
- La ruta debe estar declarada en
backendRoutes, admitirGETy tenerauth: "session"o"admin"(la llama el navegador con la sesión del usuario). En una entrada de administración debe ser"admin". - Devuelve un número o
{ "count": n }. Se muestra hasta 999 (a partir de ahí, «999+»); un valor no numérico o un error simplemente no muestra insignia. refreshSecondsva de 30 s a 3600 s (1 h); por defecto 120 s. El refresco está acotado a propósito: no uses la insignia para datos en tiempo real.- Si la ruta exige un
minLevelmayor que el de la entrada, el validador avisa: la insignia no aparecería para los niveles intermedios.
Seguridad: ocultar no es el control#
Regla
Ocultar una entrada no protege la página. El control está en el servidor: la página conserva su auth: "admin"/minLevel (se comprueba en /api/expansions/page-access) y sus CALL_BACKEND y la ruta de la insignia se protegen por nivel.
- La configuración que recibe el navegador (
/api/config) ya no incluye las entradas ni las páginas de administración que el nivel del usuario no alcanza. - Falla cerrado: la sección
adminexigeauth: "admin"; la entrada no puede ser más permisiva que su página (niauthsessionsobre una páginaadmin, ni unminLevelmenor). El validador lo rechaza al publicar y al cargar. - Las funciones de
api.functionssiguen validando sus argumentos y permisos: la página es solo la interfaz.
URLs finales#
| Página | URL |
|---|---|
Cualquier mount PAGE | /extensions/<path> |
Páginas de la sección admin, además, dentro de la consola | /admin/x/<path> |
| Ejemplo de esta guía | /extensions/metrics · /admin/x/metrics |
Estados de la entrada#
| Situación | Resultado |
|---|---|
| Extensión desactivada por el usuario | No aparece. |
No está en /api/config (desinstalada, pausada por dependencias o versión incompatible) | No aparece. |
| Bloqueada por IA o por una dependencia | Aparece deshabilitada con el motivo. |
| Nivel insuficiente (admin) | No llega al navegador. |
Móvil y accesibilidad#
- El menú móvil es la misma barra lateral en un cajón;
mobile: falseoculta la entrada ahí (sigue en escritorio). - Las entradas son enlaces reales con nombre accesible; la insignia se anuncia como parte del nombre y las deshabilitadas indican el motivo. Los componentes de página son accesibles por teclado: árbol WAI-ARIA, divisor redimensionable con flechas, tooltips de gráficos con foco y leyendas operables.
- Las páginas se adaptan:
SPLIT_PANEse apila en pantallas estrechas yPAGE_HEADERbaja las acciones debajo del título.
Límites y compatibilidad#
- Máximo 12 entradas por extensión, etiquetas de hasta 40 caracteres,
orderhasta 1000, insignia hasta 999+. - Una entrada solo enlaza páginas de su propia extensión; no hay enlaces externos ni entradas sin página.
- Compatibilidad: los componentes de página y la
TABLEavanzada exigenui.pages.v1;navEntriesexigenav.entries.v1. Los clientes antiguos no reciben versiones con estas capacidades: el backend sirve a cada instancia la versión más alta cuyos requisitos cumple, así que ven la versión anterior de la extensión (o ninguna).
Siguientes lecturas: el kit de componentes (incluida la sección de componentes de página completa), Crear una extensión y Extensiones.