feat: agregar página de debug para Metabase
- Crear componente MetabaseCardDisplay para mostrar detalles de queries - Crear componente MetabaseCardsTable para listar todas las queries - Crear página /metabase-debug con vistas de tabla, cards y queries Panorama - Agregar API routes para cards de Metabase (GET, POST, export) - Actualizar metabase.ts para soportar API Key authentication - Agregar configuración de Metabase API Key en nuxt.config.ts - Documentar todos los endpoints disponibles en METABASE_API_ENDPOINTS.md
This commit is contained in:
269
METABASE_API_ENDPOINTS.md
Normal file
269
METABASE_API_ENDPOINTS.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 🔌 REPORTE DE ENDPOINTS API DE METABASE
|
||||
|
||||
**Fecha:** 2025-10-14
|
||||
**Instancia:** metabase.nucleoriofrio.com
|
||||
**Autenticación:** X-API-KEY header
|
||||
**Formato:** JSON
|
||||
|
||||
---
|
||||
|
||||
## 📋 ENDPOINTS PRINCIPALES
|
||||
|
||||
### 🎯 **CARDS (Questions/Queries)**
|
||||
|
||||
Estos son los endpoints MÁS IMPORTANTES para nuestro caso, ya que trabajaremos con las 9 queries documentadas.
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/card` | Listar todas las cards/preguntas |
|
||||
| `GET` | `/api/card/:id` | Obtener detalles de una card específica |
|
||||
| `POST` | `/api/card` | Crear una nueva card |
|
||||
| `PUT` | `/api/card/:id` | Actualizar una card existente |
|
||||
| `DELETE` | `/api/card/:id` | Eliminar una card |
|
||||
| `POST` | `/api/card/:id/query` | **⭐ EJECUTAR una query y obtener resultados** |
|
||||
| `GET` | `/api/card/:id/query` | Ejecutar query con caché |
|
||||
| `GET` | `/api/card/:id/query/:export-format` | Exportar resultados (csv, json, xlsx) |
|
||||
|
||||
**💡 Endpoint clave:** `/api/card/:id/query` - Este lo usaremos para ejecutar las 9 queries con parámetros.
|
||||
|
||||
---
|
||||
|
||||
### 🗄️ **DATABASE**
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/database` | Listar todas las bases de datos |
|
||||
| `GET` | `/api/database/:id` | Obtener detalles de una BD |
|
||||
| `POST` | `/api/database` | Crear conexión a BD |
|
||||
| `PUT` | `/api/database/:id` | Actualizar conexión |
|
||||
| `POST` | `/api/database/:id/sync_schema` | Sincronizar esquema |
|
||||
| `GET` | `/api/database/:id/metadata` | Obtener metadata de tablas/campos |
|
||||
| `POST` | `/api/database/validate` | Validar conexión antes de guardar |
|
||||
|
||||
---
|
||||
|
||||
### 📊 **DATASET (Queries Ad-hoc)**
|
||||
|
||||
Endpoints para ejecutar queries SQL nativas sin crear cards.
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/dataset` | **⭐ Ejecutar query SQL nativa directamente** |
|
||||
| `POST` | `/api/dataset/:export-format` | Ejecutar y exportar (csv, json, xlsx) |
|
||||
|
||||
**💡 Uso importante:** Podemos usar `/api/dataset` para probar queries SQL antes de crearlas como cards.
|
||||
|
||||
**Ejemplo de payload:**
|
||||
```json
|
||||
{
|
||||
"database": 2,
|
||||
"type": "native",
|
||||
"native": {
|
||||
"query": "SELECT * FROM vista_detalle_ingresos LIMIT 10",
|
||||
"template-tags": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 📁 **COLLECTION**
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/collection` | Listar colecciones |
|
||||
| `GET` | `/api/collection/:id` | Detalles de colección |
|
||||
| `GET` | `/api/collection/:id/items` | Items dentro de colección |
|
||||
| `POST` | `/api/collection` | Crear colección |
|
||||
| `PUT` | `/api/collection/:id` | Actualizar colección |
|
||||
|
||||
---
|
||||
|
||||
### 📈 **DASHBOARD**
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/dashboard` | Listar dashboards |
|
||||
| `GET` | `/api/dashboard/:id` | Detalles de dashboard |
|
||||
| `POST` | `/api/dashboard` | Crear dashboard |
|
||||
| `PUT` | `/api/dashboard/:id` | Actualizar dashboard |
|
||||
| `POST` | `/api/dashboard/:id/cards` | Agregar card a dashboard |
|
||||
| `GET` | `/api/dashboard/:id/params/:param-key/values` | Valores para parámetro |
|
||||
|
||||
---
|
||||
|
||||
### 🔍 **TABLE & FIELD**
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/table/:id` | Detalles de tabla |
|
||||
| `GET` | `/api/table/:id/query_metadata` | Metadata completa de tabla |
|
||||
| `GET` | `/api/field/:id` | Detalles de campo |
|
||||
| `POST` | `/api/field/:id/values` | Valores únicos de un campo |
|
||||
|
||||
---
|
||||
|
||||
### 👤 **USER & SESSION**
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/user/current` | Usuario autenticado actual |
|
||||
| `GET` | `/api/user` | Listar usuarios |
|
||||
| `POST` | `/api/session` | Login (crear sesión) |
|
||||
| `DELETE` | `/api/session` | Logout |
|
||||
|
||||
---
|
||||
|
||||
### ⚙️ **SETTINGS**
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/setting` | Obtener configuraciones |
|
||||
| `PUT` | `/api/setting/:key` | Actualizar configuración |
|
||||
|
||||
---
|
||||
|
||||
### 🔐 **PERMISSIONS**
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/permissions/graph` | Ver árbol de permisos |
|
||||
| `PUT` | `/api/permissions/graph` | Actualizar permisos |
|
||||
| `GET` | `/api/permissions/group` | Listar grupos |
|
||||
|
||||
---
|
||||
|
||||
### 📤 **UPLOAD** (Anteriormente CSV)
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/upload/csv` | Subir CSV como tabla |
|
||||
|
||||
---
|
||||
|
||||
### 📧 **NOTIFICATION** (Reemplaza Pulse/Alert)
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/notification` | Listar notificaciones |
|
||||
| `POST` | `/api/notification` | Crear notificación |
|
||||
| `PUT` | `/api/notification/:id` | Actualizar notificación |
|
||||
|
||||
---
|
||||
|
||||
### 🔧 **UTIL & ANALYTICS**
|
||||
|
||||
| Método | Endpoint | Descripción |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/analytics/anonymous-stats` | Estadísticas de uso (antes /util/stats) |
|
||||
| `GET` | `/api/util/logs` | Logs del sistema |
|
||||
| `POST` | `/api/util/password_check` | Validar contraseña |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ENDPOINTS CRÍTICOS PARA NUESTRO PROYECTO
|
||||
|
||||
### 1. **Ejecutar queries con parámetros**
|
||||
```http
|
||||
POST /api/card/:id/query
|
||||
Content-Type: application/json
|
||||
X-API-KEY: mb_bRFd1DTqU1eK1GeYFo3z0WhwKcqdA5qHNCXko3ZV6FU=
|
||||
|
||||
{
|
||||
"parameters": [
|
||||
{"type": "date/single", "target": ["variable", ["template-tag", "fecha_desde"]], "value": "2025-01-01"},
|
||||
{"type": "date/single", "target": ["variable", ["template-tag", "fecha_hasta"]], "value": "2025-12-31"},
|
||||
{"type": "category", "target": ["variable", ["template-tag", "incluir_anulados"]], "value": false}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Ejecutar SQL nativa directamente**
|
||||
```http
|
||||
POST /api/dataset
|
||||
Content-Type: application/json
|
||||
X-API-KEY: mb_bRFd1DTqU1eK1GeYFo3z0WhwKcqdA5qHNCXko3ZV6FU=
|
||||
|
||||
{
|
||||
"database": 2,
|
||||
"type": "native",
|
||||
"native": {
|
||||
"query": "SELECT COALESCE(SUM(...), 0) as total FROM vista_detalle_ingresos WHERE ...",
|
||||
"template-tags": {
|
||||
"incluir_anulados": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. **Listar todas las cards para encontrar IDs**
|
||||
```http
|
||||
GET /api/card?f=all
|
||||
X-API-KEY: mb_bRFd1DTqU1eK1GeYFo3z0WhwKcqdA5qHNCXko3ZV6FU=
|
||||
```
|
||||
|
||||
### 4. **Obtener metadata de una card**
|
||||
```http
|
||||
GET /api/card/:id
|
||||
X-API-KEY: mb_bRFd1DTqU1eK1GeYFo3z0WhwKcqdA5qHNCXko3ZV6FU=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 NOTAS IMPORTANTES
|
||||
|
||||
1. **Autenticación:** Todos los requests requieren header `X-API-KEY: mb_bRFd1DTqU1eK1GeYFo3z0WhwKcqdA5qHNCXko3ZV6FU=`
|
||||
|
||||
2. **Estructura de respuesta de queries:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"rows": [...], // Array de arrays con los valores
|
||||
"cols": [...], // Metadata de columnas (nombre, tipo, etc)
|
||||
"rows_truncated": 1000, // Límite de filas
|
||||
"native_form": {...} // Query SQL ejecutada
|
||||
},
|
||||
"database_id": 2,
|
||||
"started_at": "...",
|
||||
"json_query": {...},
|
||||
"average_execution_time": null,
|
||||
"status": "completed",
|
||||
"context": "ad-hoc",
|
||||
"row_count": 1,
|
||||
"running_time": 45
|
||||
}
|
||||
```
|
||||
|
||||
3. **IDs de las 9 queries del Panorama:**
|
||||
- Query 1: `/api/card/38/query` (panorama_totales_financieros_principales)
|
||||
- Query 2-9: Por determinar
|
||||
|
||||
4. **Parámetros template-tags:**
|
||||
- `fecha_desde`: tipo `date/single`, opcional
|
||||
- `fecha_hasta`: tipo `date/single`, opcional
|
||||
- `incluir_anulados`: tipo `boolean`, default `false`
|
||||
|
||||
5. **Testing de queries:**
|
||||
- Usar `/api/dataset` para probar SQL antes de crear/editar cards
|
||||
- Usar `/api/card/:id/query` para probar cards existentes con diferentes parámetros
|
||||
|
||||
---
|
||||
|
||||
## 🚀 PRÓXIMOS PASOS
|
||||
|
||||
1. ✅ Listar todas las cards con `GET /api/card?f=all` para identificar las 9 queries
|
||||
2. ✅ Para cada query (1-9):
|
||||
- Obtener metadata con `GET /api/card/:id`
|
||||
- Ejecutar con parámetros default usando `POST /api/card/:id/query`
|
||||
- Verificar estructura de respuesta
|
||||
- Validar que coincida con la especificación en `METABASE_QUERIES_PANORAMA.md`
|
||||
3. ✅ Ajustar queries que no devuelvan la estructura correcta
|
||||
|
||||
---
|
||||
|
||||
**Documento creado:** 2025-10-14
|
||||
**Autor:** Claude Code
|
||||
**Proyecto:** Analítica Núcleo - Exploración API Metabase
|
||||
187
nuxt4-app/app/components/metabase/MetabaseCardDisplay.vue
Normal file
187
nuxt4-app/app/components/metabase/MetabaseCardDisplay.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold">{{ card.name }}</h3>
|
||||
<p v-if="card.description" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
</div>
|
||||
<UBadge :color="getStatusColor(card)">
|
||||
ID: {{ card.id }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Card Details -->
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium">Database ID:</span>
|
||||
<span class="ml-2">{{ card.database_id }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Query Type:</span>
|
||||
<span class="ml-2">{{ card.query_type || card.dataset_query?.type }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Collection:</span>
|
||||
<span class="ml-2">{{ card.collection_id || 'Root' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Created:</span>
|
||||
<span class="ml-2">{{ formatDate(card.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Query Preview -->
|
||||
<div v-if="card.dataset_query?.native?.query" class="mt-4">
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-sm font-medium text-primary">
|
||||
Ver SQL
|
||||
</summary>
|
||||
<pre class="mt-2 p-3 bg-gray-50 dark:bg-gray-900 rounded text-xs overflow-x-auto">{{ card.dataset_query.native.query }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2 mt-4 pt-4 border-t dark:border-gray-700">
|
||||
<UButton
|
||||
@click="executeQuery"
|
||||
:loading="executing"
|
||||
color="primary"
|
||||
size="sm"
|
||||
>
|
||||
Ejecutar Query
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
@click="executeQueryCached"
|
||||
:loading="executingCached"
|
||||
color="gray"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
>
|
||||
Con Caché
|
||||
</UButton>
|
||||
|
||||
<UDropdown :items="exportItems">
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
trailing-icon="i-heroicons-chevron-down-20-solid"
|
||||
>
|
||||
Exportar
|
||||
</UButton>
|
||||
</UDropdown>
|
||||
</div>
|
||||
|
||||
<!-- Query Results -->
|
||||
<div v-if="queryResult" class="mt-4">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h4 class="font-medium text-sm">Resultados</h4>
|
||||
<UBadge color="green">
|
||||
{{ queryResult.data?.rows?.length || 0 }} filas en {{ queryResult.running_time || 0 }}ms
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<pre class="p-3 bg-gray-50 dark:bg-gray-900 rounded text-xs">{{ JSON.stringify(queryResult.data, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="red"
|
||||
variant="soft"
|
||||
:title="error"
|
||||
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', color: 'red', variant: 'link' }"
|
||||
@close="error = null"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
card: any
|
||||
}>()
|
||||
|
||||
const executing = ref(false)
|
||||
const executingCached = ref(false)
|
||||
const queryResult = ref<any>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const exportItems = [[
|
||||
{
|
||||
label: 'CSV',
|
||||
icon: 'i-heroicons-document-text',
|
||||
click: () => downloadExport('csv')
|
||||
},
|
||||
{
|
||||
label: 'JSON',
|
||||
icon: 'i-heroicons-code-bracket',
|
||||
click: () => downloadExport('json')
|
||||
},
|
||||
{
|
||||
label: 'XLSX',
|
||||
icon: 'i-heroicons-table-cells',
|
||||
click: () => downloadExport('xlsx')
|
||||
}
|
||||
]]
|
||||
|
||||
function getStatusColor(card: any) {
|
||||
if (card.archived) return 'red'
|
||||
if (card.dataset) return 'blue'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
if (!dateString) return 'N/A'
|
||||
return new Date(dateString).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
async function executeQuery() {
|
||||
executing.value = true
|
||||
error.value = null
|
||||
queryResult.value = null
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/api/metabase/cards/${props.card.id}/query`, {
|
||||
method: 'POST'
|
||||
})
|
||||
queryResult.value = result
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Error al ejecutar la query'
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQueryCached() {
|
||||
executingCached.value = true
|
||||
error.value = null
|
||||
queryResult.value = null
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/api/metabase/cards/${props.card.id}/query`)
|
||||
queryResult.value = result
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Error al ejecutar la query con caché'
|
||||
} finally {
|
||||
executingCached.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadExport(format: 'csv' | 'json' | 'xlsx') {
|
||||
const url = `${window.location.origin}/api/metabase/cards/${props.card.id}/query/${format}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
266
nuxt4-app/app/components/metabase/MetabaseCardsTable.vue
Normal file
266
nuxt4-app/app/components/metabase/MetabaseCardsTable.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Filters and Search -->
|
||||
<div class="flex gap-3">
|
||||
<UInput
|
||||
v-model="search"
|
||||
placeholder="Buscar por nombre o ID..."
|
||||
icon="i-heroicons-magnifying-glass"
|
||||
class="flex-1"
|
||||
/>
|
||||
<USelectMenu
|
||||
v-model="selectedFilter"
|
||||
:options="filterOptions"
|
||||
placeholder="Filtrar..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<UTable
|
||||
:rows="filteredCards"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
@select="selectCard"
|
||||
>
|
||||
<template #id-data="{ row }">
|
||||
<UBadge color="gray" variant="subtle">{{ row.id }}</UBadge>
|
||||
</template>
|
||||
|
||||
<template #name-data="{ row }">
|
||||
<div>
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div v-if="row.description" class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-md">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #query_type-data="{ row }">
|
||||
<UBadge :color="getQueryTypeColor(row.query_type || row.dataset_query?.type)">
|
||||
{{ row.query_type || row.dataset_query?.type || 'N/A' }}
|
||||
</UBadge>
|
||||
</template>
|
||||
|
||||
<template #database_id-data="{ row }">
|
||||
<span class="text-sm">{{ row.database_id || 'N/A' }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions-data="{ row }">
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
@click.stop="executeCard(row)"
|
||||
:loading="executingCards.has(row.id)"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
>
|
||||
Ejecutar
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
@click.stop="executeCachedCard(row)"
|
||||
:loading="executingCachedCards.has(row.id)"
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
>
|
||||
Caché
|
||||
</UButton>
|
||||
|
||||
<UDropdown :items="getExportItems(row)">
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="i-heroicons-arrow-down-tray"
|
||||
/>
|
||||
</UDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
<!-- Results Modal -->
|
||||
<UModal v-model="showResults">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">Resultados: {{ selectedCardForResults?.name }}</h3>
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
icon="i-heroicons-x-mark"
|
||||
@click="showResults = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="currentResult" class="space-y-3">
|
||||
<div class="flex gap-4 text-sm">
|
||||
<div>
|
||||
<span class="font-medium">Filas:</span>
|
||||
<span class="ml-2">{{ currentResult.data?.rows?.length || 0 }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Tiempo:</span>
|
||||
<span class="ml-2">{{ currentResult.running_time || 0 }}ms</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Estado:</span>
|
||||
<UBadge :color="currentResult.status === 'completed' ? 'green' : 'yellow'">
|
||||
{{ currentResult.status }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto max-h-96">
|
||||
<pre class="p-3 bg-gray-50 dark:bg-gray-900 rounded text-xs">{{ JSON.stringify(currentResult.data, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="currentError"
|
||||
color="red"
|
||||
variant="soft"
|
||||
:title="currentError"
|
||||
class="mt-3"
|
||||
/>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
cards: any[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [card: any]
|
||||
}>()
|
||||
|
||||
const search = ref('')
|
||||
const selectedFilter = ref('all')
|
||||
const executingCards = ref(new Set<number>())
|
||||
const executingCachedCards = ref(new Set<number>())
|
||||
const showResults = ref(false)
|
||||
const currentResult = ref<any>(null)
|
||||
const currentError = ref<string | null>(null)
|
||||
const selectedCardForResults = ref<any>(null)
|
||||
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: 'Todas' },
|
||||
{ value: 'native', label: 'SQL Nativo' },
|
||||
{ value: 'query', label: 'Query Builder' }
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Nombre' },
|
||||
{ key: 'query_type', label: 'Tipo' },
|
||||
{ key: 'database_id', label: 'DB' },
|
||||
{ key: 'actions', label: 'Acciones' }
|
||||
]
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
let result = props.cards
|
||||
|
||||
// Filter by search
|
||||
if (search.value) {
|
||||
const searchLower = search.value.toLowerCase()
|
||||
result = result.filter(card =>
|
||||
card.name?.toLowerCase().includes(searchLower) ||
|
||||
card.id?.toString().includes(searchLower) ||
|
||||
card.description?.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (selectedFilter.value !== 'all') {
|
||||
result = result.filter(card => {
|
||||
const type = card.query_type || card.dataset_query?.type
|
||||
return type === selectedFilter.value
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function getQueryTypeColor(type: string) {
|
||||
switch (type) {
|
||||
case 'native':
|
||||
return 'blue'
|
||||
case 'query':
|
||||
return 'green'
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
||||
|
||||
function selectCard(card: any) {
|
||||
emit('select', card)
|
||||
}
|
||||
|
||||
async function executeCard(card: any) {
|
||||
executingCards.value.add(card.id)
|
||||
currentError.value = null
|
||||
currentResult.value = null
|
||||
selectedCardForResults.value = card
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/api/metabase/cards/${card.id}/query`, {
|
||||
method: 'POST'
|
||||
})
|
||||
currentResult.value = result
|
||||
showResults.value = true
|
||||
} catch (e: any) {
|
||||
currentError.value = e.message || 'Error al ejecutar la query'
|
||||
showResults.value = true
|
||||
} finally {
|
||||
executingCards.value.delete(card.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCachedCard(card: any) {
|
||||
executingCachedCards.value.add(card.id)
|
||||
currentError.value = null
|
||||
currentResult.value = null
|
||||
selectedCardForResults.value = card
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/api/metabase/cards/${card.id}/query`)
|
||||
currentResult.value = result
|
||||
showResults.value = true
|
||||
} catch (e: any) {
|
||||
currentError.value = e.message || 'Error al ejecutar la query con caché'
|
||||
showResults.value = true
|
||||
} finally {
|
||||
executingCachedCards.value.delete(card.id)
|
||||
}
|
||||
}
|
||||
|
||||
function getExportItems(card: any) {
|
||||
return [[
|
||||
{
|
||||
label: 'CSV',
|
||||
icon: 'i-heroicons-document-text',
|
||||
click: () => downloadExport(card, 'csv')
|
||||
},
|
||||
{
|
||||
label: 'JSON',
|
||||
icon: 'i-heroicons-code-bracket',
|
||||
click: () => downloadExport(card, 'json')
|
||||
},
|
||||
{
|
||||
label: 'XLSX',
|
||||
icon: 'i-heroicons-table-cells',
|
||||
click: () => downloadExport(card, 'xlsx')
|
||||
}
|
||||
]]
|
||||
}
|
||||
|
||||
function downloadExport(card: any, format: 'csv' | 'json' | 'xlsx') {
|
||||
const url = `${window.location.origin}/api/metabase/cards/${card.id}/query/${format}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
203
nuxt4-app/app/pages/metabase-debug.vue
Normal file
203
nuxt4-app/app/pages/metabase-debug.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Metabase Debug</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Herramienta de debugging para queries de Metabase
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
@click="refreshCards"
|
||||
:loading="loading"
|
||||
icon="i-heroicons-arrow-path"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
>
|
||||
Actualizar
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<UCard>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-primary">{{ cards.length }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Total Cards</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ nativeQueries }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">SQL Nativo</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ queryBuilderQueries }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Query Builder</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-gray-600">{{ panoramaQueries.length }}/9</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Queries Panorama</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="red"
|
||||
variant="soft"
|
||||
:title="error"
|
||||
:close-button="{ icon: 'i-heroicons-x-mark-20-solid', color: 'red', variant: 'link' }"
|
||||
@close="error = null"
|
||||
/>
|
||||
|
||||
<!-- Tabs -->
|
||||
<UTabs v-model="selectedTab" :items="tabs">
|
||||
<template #default="{ item }">
|
||||
<!-- Table View -->
|
||||
<div v-if="item.key === 'table'" class="py-4">
|
||||
<MetabaseCardsTable
|
||||
:cards="cards"
|
||||
:loading="loading"
|
||||
@select="selectCard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cards View -->
|
||||
<div v-if="item.key === 'cards'" class="py-4">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<MetabaseCardDisplay
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panorama Queries -->
|
||||
<div v-if="item.key === 'panorama'" class="py-4 space-y-4">
|
||||
<UAlert
|
||||
color="blue"
|
||||
variant="soft"
|
||||
title="Queries del Panorama Facturador"
|
||||
description="Estas son las 9 queries documentadas en METABASE_QUERIES_PANORAMA.md"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<MetabaseCardDisplay
|
||||
v-for="card in panoramaQueries"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="panoramaQueries.length < 9"
|
||||
color="yellow"
|
||||
variant="soft"
|
||||
:title="`Faltan ${9 - panoramaQueries.length} queries por encontrar`"
|
||||
description="Busca en la tabla las queries que contengan 'panorama' en su nombre"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selected Card Detail -->
|
||||
<div v-if="item.key === 'detail' && selectedCard" class="py-4">
|
||||
<MetabaseCardDisplay :card="selectedCard" />
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const cards = ref<any[]>([])
|
||||
const selectedCard = ref<any>(null)
|
||||
const selectedTab = ref(0)
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'table',
|
||||
label: 'Vista Tabla',
|
||||
icon: 'i-heroicons-table-cells'
|
||||
},
|
||||
{
|
||||
key: 'cards',
|
||||
label: 'Vista Cards',
|
||||
icon: 'i-heroicons-squares-2x2'
|
||||
},
|
||||
{
|
||||
key: 'panorama',
|
||||
label: 'Queries Panorama',
|
||||
icon: 'i-heroicons-chart-bar'
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
label: 'Detalle',
|
||||
icon: 'i-heroicons-document-magnifying-glass',
|
||||
disabled: !selectedCard.value
|
||||
}
|
||||
]
|
||||
|
||||
const nativeQueries = computed(() => {
|
||||
return cards.value.filter(card => {
|
||||
const type = card.query_type || card.dataset_query?.type
|
||||
return type === 'native'
|
||||
}).length
|
||||
})
|
||||
|
||||
const queryBuilderQueries = computed(() => {
|
||||
return cards.value.filter(card => {
|
||||
const type = card.query_type || card.dataset_query?.type
|
||||
return type === 'query'
|
||||
}).length
|
||||
})
|
||||
|
||||
const panoramaQueries = computed(() => {
|
||||
return cards.value.filter(card =>
|
||||
card.name?.toLowerCase().includes('panorama')
|
||||
)
|
||||
})
|
||||
|
||||
async function fetchCards() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const result = await $fetch('/api/metabase/cards?f=all')
|
||||
cards.value = Array.isArray(result) ? result : []
|
||||
} catch (e: any) {
|
||||
error.value = e.message || 'Error al cargar las cards de Metabase'
|
||||
console.error('Error fetching cards:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCards() {
|
||||
fetchCards()
|
||||
}
|
||||
|
||||
function selectCard(card: any) {
|
||||
selectedCard.value = card
|
||||
selectedTab.value = 3 // Switch to detail tab
|
||||
}
|
||||
|
||||
// Load cards on mount
|
||||
onMounted(() => {
|
||||
fetchCards()
|
||||
})
|
||||
</script>
|
||||
@@ -144,6 +144,7 @@ export default defineNuxtConfig({
|
||||
// Server-side only
|
||||
// Use container name for Docker internal communication
|
||||
metabaseUrl: process.env.METABASE_URL || 'http://metabase:3000',
|
||||
metabaseApiKey: process.env.METABASE_API_KEY || '',
|
||||
metabaseEmail: process.env.METABASE_EMAIL || 'claudeCode0@nucleoriofrio.com',
|
||||
metabasePassword: process.env.METABASE_PASSWORD || 'vK^NyZdZDH#p',
|
||||
// Public (client + server)
|
||||
|
||||
24
nuxt4-app/server/api/metabase/cards/[id]/index.get.ts
Normal file
24
nuxt4-app/server/api/metabase/cards/[id]/index.get.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Get a specific Metabase card by ID
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Card ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
const card = await getMetabaseCard(parseInt(id))
|
||||
return card
|
||||
} catch (error: any) {
|
||||
console.error('[API] Failed to get Metabase card:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: error.statusMessage || 'Failed to fetch card'
|
||||
})
|
||||
}
|
||||
})
|
||||
24
nuxt4-app/server/api/metabase/cards/[id]/query.get.ts
Normal file
24
nuxt4-app/server/api/metabase/cards/[id]/query.get.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Execute a Metabase card query with cache (GET)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Card ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
const result = await executeCardQueryCached(parseInt(id))
|
||||
return result
|
||||
} catch (error: any) {
|
||||
console.error('[API] Failed to execute cached query:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: error.statusMessage || 'Failed to execute cached query'
|
||||
})
|
||||
}
|
||||
})
|
||||
27
nuxt4-app/server/api/metabase/cards/[id]/query.post.ts
Normal file
27
nuxt4-app/server/api/metabase/cards/[id]/query.post.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Execute a Metabase card query (POST)
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const id = getRouterParam(event, 'id')
|
||||
|
||||
if (!id) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Card ID is required'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const parameters = body?.parameters
|
||||
|
||||
const result = await executeCardQuery(parseInt(id), parameters)
|
||||
return result
|
||||
} catch (error: any) {
|
||||
console.error('[API] Failed to execute Metabase card query:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: error.statusMessage || 'Failed to execute query'
|
||||
})
|
||||
}
|
||||
})
|
||||
18
nuxt4-app/server/api/metabase/cards/index.get.ts
Normal file
18
nuxt4-app/server/api/metabase/cards/index.get.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Get all Metabase cards/questions
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const query = getQuery(event)
|
||||
const filter = query.f as string | undefined
|
||||
|
||||
const cards = await getMetabaseCards(filter)
|
||||
return cards
|
||||
} catch (error: any) {
|
||||
console.error('[API] Failed to get Metabase cards:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: error.statusMessage || 'Failed to fetch cards'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const METABASE_URL = config.metabaseUrl || 'http://metabase:3000'
|
||||
const METABASE_API_KEY = config.metabaseApiKey || ''
|
||||
const METABASE_EMAIL = config.metabaseEmail || 'claudeCode0@nucleoriofrio.com'
|
||||
const METABASE_PASSWORD = config.metabasePassword || 'vK^NyZdZDH#p'
|
||||
|
||||
@@ -50,29 +51,37 @@ export async function getMetabaseToken(): Promise<string> {
|
||||
|
||||
/**
|
||||
* Make an authenticated request to Metabase API
|
||||
* Supports both API Key and Session Token authentication
|
||||
*/
|
||||
export async function metabaseFetch<T = any>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers as Record<string, string>
|
||||
}
|
||||
|
||||
// Prefer API Key if available
|
||||
if (METABASE_API_KEY) {
|
||||
headers['X-API-KEY'] = METABASE_API_KEY
|
||||
} else {
|
||||
const token = await getMetabaseToken()
|
||||
headers['X-Metabase-Session'] = token
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch<T>(`${METABASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'X-Metabase-Session': token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
headers
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error: any) {
|
||||
console.error(`[Metabase] Request failed for ${endpoint}:`, error)
|
||||
|
||||
// If token is invalid, clear it and retry once
|
||||
if (error.statusCode === 401) {
|
||||
// If using session token and it's invalid, clear it and retry once
|
||||
if (error.statusCode === 401 && !METABASE_API_KEY) {
|
||||
console.log('[Metabase] Token invalid, clearing and retrying...')
|
||||
sessionToken = null
|
||||
tokenExpiry = 0
|
||||
@@ -126,3 +135,36 @@ export async function queryMetabaseTable(databaseId: number, tableId: number, qu
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cards/questions
|
||||
*/
|
||||
export async function getMetabaseCards(filter?: string) {
|
||||
const endpoint = filter ? `/api/card?f=${filter}` : '/api/card'
|
||||
return metabaseFetch(endpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific card by ID
|
||||
*/
|
||||
export async function getMetabaseCard(cardId: number) {
|
||||
return metabaseFetch(`/api/card/${cardId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a card query with optional parameters
|
||||
*/
|
||||
export async function executeCardQuery(cardId: number, parameters?: any[]) {
|
||||
const body = parameters ? { parameters } : {}
|
||||
return metabaseFetch(`/api/card/${cardId}/query`, {
|
||||
method: 'POST',
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a card query with cache (GET)
|
||||
*/
|
||||
export async function executeCardQueryCached(cardId: number) {
|
||||
return metabaseFetch(`/api/card/${cardId}/query`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user