feat: implement data explorer with Metabase integration
- Replace maintenance mode with functional data explorer - Connect to Metabase API for database and table listing - Implement raw data viewer with search and column filtering - Add support for querying tables directly from Metabase - Limit queries to 1000 records by default for performance
This commit is contained in:
@@ -1,15 +1,346 @@
|
||||
<template>
|
||||
<MaintenanceMode
|
||||
title="Explorador de Datos"
|
||||
description="El explorador de datos está temporalmente deshabilitado mientras realizamos mejoras en el sistema de consultas."
|
||||
icon="i-lucide-table"
|
||||
technical-info="La funcionalidad de exploración de datos requiere reconexión con la fuente de datos. Actualmente en proceso de migración a nueva arquitectura."
|
||||
<div class="flex flex-col gap-6">
|
||||
<UCard class="brand-card border border-transparent backdrop-blur-sm">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-xl font-semibold text-[var(--brand-text)]">Explorador de Datos</h2>
|
||||
<p class="text-sm text-[var(--brand-text-muted)]">
|
||||
Explore y visualice los datos de las tablas disponibles en Metabase.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Database and Table Selection -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Database Selector -->
|
||||
<UFieldGroup label="Base de datos">
|
||||
<USelect
|
||||
v-model="selectedDatabaseId"
|
||||
:options="databaseOptions"
|
||||
placeholder="Seleccionar base de datos"
|
||||
:loading="loadingDatabases"
|
||||
:disabled="loadingDatabases"
|
||||
/>
|
||||
</UFieldGroup>
|
||||
|
||||
<!-- Table Selector -->
|
||||
<UFieldGroup label="Tabla" v-if="selectedDatabaseId">
|
||||
<USelect
|
||||
v-model="selectedTableId"
|
||||
:options="tableOptions"
|
||||
placeholder="Seleccionar tabla"
|
||||
:loading="loadingTables"
|
||||
:disabled="loadingTables || !selectedDatabaseId"
|
||||
/>
|
||||
</UFieldGroup>
|
||||
|
||||
<!-- Load Data Button -->
|
||||
<UButton
|
||||
v-if="selectedTableId"
|
||||
:loading="loadingData"
|
||||
:disabled="!selectedTableId"
|
||||
@click="loadTableData"
|
||||
color="primary"
|
||||
icon="i-lucide-refresh-cw"
|
||||
>
|
||||
Cargar datos
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Loading State -->
|
||||
<UCard v-if="loadingData" class="brand-card border border-transparent">
|
||||
<div class="flex items-center justify-center gap-3 py-10 text-[var(--brand-text-muted)]">
|
||||
<span class="inline-flex h-8 w-8 animate-spin rounded-full border-2 border-[#c08040] border-t-transparent align-middle" aria-hidden="true" />
|
||||
<span class="text-sm uppercase tracking-[0.3em]">Cargando datos...</span>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Error State -->
|
||||
<UCard v-else-if="error" class="brand-card border border-transparent">
|
||||
<div class="py-10 text-center">
|
||||
<div class="rounded-lg border border-red-500/40 bg-red-500/18 p-4 text-sm text-red-200 max-w-md mx-auto">
|
||||
{{ error }}
|
||||
</div>
|
||||
<UButton
|
||||
class="mt-4"
|
||||
@click="loadTableData"
|
||||
color="primary"
|
||||
>
|
||||
Reintentar
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Table Data Display -->
|
||||
<UCard v-else-if="tableData && tableData.length > 0" class="brand-card border border-transparent">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold brand-section-title">
|
||||
{{ selectedTableName }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-[var(--brand-text-muted)]">
|
||||
<span class="brand-pill inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs">
|
||||
{{ tableData.length }} registros
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1 divide-y divide-accented w-full">
|
||||
<!-- Search Input -->
|
||||
<div class="flex items-center gap-2 px-4 py-3.5 overflow-x-auto">
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
class="max-w-sm min-w-[12ch]"
|
||||
placeholder="Buscar en todos los campos..."
|
||||
icon="i-lucide-search"
|
||||
/>
|
||||
|
||||
<UButton
|
||||
v-if="tableColumns.length > 0"
|
||||
label="Columnas"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
trailing-icon="i-lucide-chevron-down"
|
||||
class="ml-auto"
|
||||
@click="showColumnSelector = !showColumnSelector"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Column Selector -->
|
||||
<div v-if="showColumnSelector" class="px-4 py-3 bg-elevated border-b border-accented">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
<label
|
||||
v-for="column in tableColumns"
|
||||
:key="column.key"
|
||||
class="flex items-center gap-2 text-sm cursor-pointer hover:text-[var(--brand-text)]"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="visibleColumns.includes(column.key)"
|
||||
@change="toggleColumn(column.key)"
|
||||
class="rounded"
|
||||
/>
|
||||
<span>{{ column.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<UTable
|
||||
:data="filteredData"
|
||||
:columns="displayColumns"
|
||||
:loading="loadingData"
|
||||
class="h-96"
|
||||
/>
|
||||
|
||||
<!-- Table Footer -->
|
||||
<div class="px-4 py-3.5 text-sm text-muted">
|
||||
{{ filteredData.length }} de {{ tableData.length }} filas mostradas.
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Empty State -->
|
||||
<UCard v-else-if="selectedTableId" class="brand-card border border-transparent">
|
||||
<div class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
|
||||
<UIcon name="i-lucide-inbox" class="mx-auto mb-4 size-12 text-[var(--brand-text-muted)]" />
|
||||
<h3 class="text-lg font-semibold text-[var(--brand-text)] mb-2">No hay datos disponibles</h3>
|
||||
<p class="text-sm text-[var(--brand-text-muted)] mb-4">
|
||||
Haz clic en "Cargar datos" para obtener la información de esta tabla.
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- No Selection State -->
|
||||
<UCard v-else class="brand-card border border-transparent">
|
||||
<div class="py-10 text-center text-sm text-[var(--brand-text-muted)]">
|
||||
Selecciona una base de datos y una tabla para explorar sus datos.
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { upperFirst } from 'scule'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
title: 'Explorador de datos'
|
||||
})
|
||||
|
||||
// State
|
||||
const selectedDatabaseId = ref<number | null>(null)
|
||||
const selectedTableId = ref<number | null>(null)
|
||||
const loadingDatabases = ref(false)
|
||||
const loadingTables = ref(false)
|
||||
const loadingData = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const showColumnSelector = ref(false)
|
||||
|
||||
const databases = ref<any[]>([])
|
||||
const tables = ref<any[]>([])
|
||||
const tableData = ref<any[]>([])
|
||||
const visibleColumns = ref<string[]>([])
|
||||
|
||||
// Computed
|
||||
const databaseOptions = computed(() => {
|
||||
return databases.value.map(db => ({
|
||||
label: db.name,
|
||||
value: db.id
|
||||
}))
|
||||
})
|
||||
|
||||
const tableOptions = computed(() => {
|
||||
return tables.value.map(table => ({
|
||||
label: `${table.display_name || table.name} (${table.row_count || 0} registros)`,
|
||||
value: table.id
|
||||
}))
|
||||
})
|
||||
|
||||
const selectedTableName = computed(() => {
|
||||
const table = tables.value.find(t => t.id === selectedTableId.value)
|
||||
return table ? (table.display_name || table.name) : ''
|
||||
})
|
||||
|
||||
const tableColumns = computed(() => {
|
||||
if (!tableData.value || tableData.value.length === 0) return []
|
||||
|
||||
const firstRow = tableData.value[0]
|
||||
return Object.keys(firstRow).map(key => ({
|
||||
key,
|
||||
label: upperFirst(key)
|
||||
}))
|
||||
})
|
||||
|
||||
const displayColumns = computed(() => {
|
||||
return tableColumns.value
|
||||
.filter(col => visibleColumns.value.includes(col.key))
|
||||
.map(col => ({
|
||||
key: col.key,
|
||||
label: col.label,
|
||||
sortable: true
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredData = computed(() => {
|
||||
if (!searchQuery.value) return tableData.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return tableData.value.filter(row => {
|
||||
return Object.values(row).some(value => {
|
||||
if (value === null || value === undefined) return false
|
||||
return String(value).toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Methods
|
||||
async function loadDatabases() {
|
||||
loadingDatabases.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/metabase/databases')
|
||||
databases.value = response as any[]
|
||||
|
||||
// Auto-select first database
|
||||
if (databases.value.length > 0) {
|
||||
selectedDatabaseId.value = databases.value[0].id
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = `Error al cargar bases de datos: ${err.message || 'Error desconocido'}`
|
||||
console.error('Error loading databases:', err)
|
||||
} finally {
|
||||
loadingDatabases.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTables() {
|
||||
if (!selectedDatabaseId.value) return
|
||||
|
||||
loadingTables.value = true
|
||||
error.value = null
|
||||
selectedTableId.value = null
|
||||
tableData.value = []
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/metabase/tables/${selectedDatabaseId.value}`)
|
||||
const metadata = response as any
|
||||
|
||||
// Metabase returns tables in the 'tables' property
|
||||
tables.value = metadata.tables || []
|
||||
} catch (err: any) {
|
||||
error.value = `Error al cargar tablas: ${err.message || 'Error desconocido'}`
|
||||
console.error('Error loading tables:', err)
|
||||
} finally {
|
||||
loadingTables.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTableData() {
|
||||
if (!selectedDatabaseId.value || !selectedTableId.value) return
|
||||
|
||||
loadingData.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/metabase/query', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
databaseId: selectedDatabaseId.value,
|
||||
tableId: selectedTableId.value,
|
||||
query: {
|
||||
limit: 1000 // Limitar a 1000 registros por defecto
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Metabase returns data in a specific format
|
||||
const result = response as any
|
||||
|
||||
if (result.data && result.data.rows && result.data.cols) {
|
||||
// Transform Metabase response to array of objects
|
||||
const columns = result.data.cols.map((col: any) => col.name)
|
||||
tableData.value = result.data.rows.map((row: any[]) => {
|
||||
const obj: any = {}
|
||||
columns.forEach((col: string, index: number) => {
|
||||
obj[col] = row[index]
|
||||
})
|
||||
return obj
|
||||
})
|
||||
|
||||
// Initialize visible columns
|
||||
visibleColumns.value = columns
|
||||
} else {
|
||||
tableData.value = []
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = `Error al cargar datos: ${err.message || 'Error desconocido'}`
|
||||
console.error('Error loading table data:', err)
|
||||
} finally {
|
||||
loadingData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleColumn(columnKey: string) {
|
||||
const index = visibleColumns.value.indexOf(columnKey)
|
||||
if (index > -1) {
|
||||
visibleColumns.value.splice(index, 1)
|
||||
} else {
|
||||
visibleColumns.value.push(columnKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(selectedDatabaseId, () => {
|
||||
loadTables()
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadDatabases()
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user