feat: agregar página de debug para Metabase
All checks were successful
build-and-deploy / build (push) Successful in 41s
build-and-deploy / deploy (push) Successful in 3s

- 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:
2025-10-14 01:34:56 -06:00
parent d0b0dc3c56
commit 90aebbde3d
10 changed files with 1069 additions and 8 deletions

269
METABASE_API_ENDPOINTS.md Normal file
View 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

View 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>

View 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>

View 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>

View File

@@ -144,6 +144,7 @@ export default defineNuxtConfig({
// Server-side only // Server-side only
// Use container name for Docker internal communication // Use container name for Docker internal communication
metabaseUrl: process.env.METABASE_URL || 'http://metabase:3000', metabaseUrl: process.env.METABASE_URL || 'http://metabase:3000',
metabaseApiKey: process.env.METABASE_API_KEY || '',
metabaseEmail: process.env.METABASE_EMAIL || 'claudeCode0@nucleoriofrio.com', metabaseEmail: process.env.METABASE_EMAIL || 'claudeCode0@nucleoriofrio.com',
metabasePassword: process.env.METABASE_PASSWORD || 'vK^NyZdZDH#p', metabasePassword: process.env.METABASE_PASSWORD || 'vK^NyZdZDH#p',
// Public (client + server) // Public (client + server)

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View 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'
})
}
})

View File

@@ -6,6 +6,7 @@
const config = useRuntimeConfig() const config = useRuntimeConfig()
const METABASE_URL = config.metabaseUrl || 'http://metabase:3000' const METABASE_URL = config.metabaseUrl || 'http://metabase:3000'
const METABASE_API_KEY = config.metabaseApiKey || ''
const METABASE_EMAIL = config.metabaseEmail || 'claudeCode0@nucleoriofrio.com' const METABASE_EMAIL = config.metabaseEmail || 'claudeCode0@nucleoriofrio.com'
const METABASE_PASSWORD = config.metabasePassword || 'vK^NyZdZDH#p' 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 * Make an authenticated request to Metabase API
* Supports both API Key and Session Token authentication
*/ */
export async function metabaseFetch<T = any>( export async function metabaseFetch<T = any>(
endpoint: string, endpoint: string,
options: RequestInit = {} options: RequestInit = {}
): Promise<T> { ): 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() const token = await getMetabaseToken()
headers['X-Metabase-Session'] = token
}
try { try {
const response = await $fetch<T>(`${METABASE_URL}${endpoint}`, { const response = await $fetch<T>(`${METABASE_URL}${endpoint}`, {
...options, ...options,
headers: { headers
...options.headers,
'X-Metabase-Session': token,
'Content-Type': 'application/json'
}
}) })
return response return response
} catch (error: any) { } catch (error: any) {
console.error(`[Metabase] Request failed for ${endpoint}:`, error) console.error(`[Metabase] Request failed for ${endpoint}:`, error)
// If token is invalid, clear it and retry once // If using session token and it's invalid, clear it and retry once
if (error.statusCode === 401) { if (error.statusCode === 401 && !METABASE_API_KEY) {
console.log('[Metabase] Token invalid, clearing and retrying...') console.log('[Metabase] Token invalid, clearing and retrying...')
sessionToken = null sessionToken = null
tokenExpiry = 0 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`)
}