486 lines
14 KiB
Vue
486 lines
14 KiB
Vue
<template>
|
|
<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">
|
|
<USelectMenu
|
|
v-model="selectedDatabase"
|
|
:items="databaseOptions"
|
|
placeholder="Seleccionar base de datos"
|
|
:loading="loadingDatabases"
|
|
:disabled="loadingDatabases"
|
|
/>
|
|
</UFieldGroup>
|
|
|
|
<!-- Table Selector -->
|
|
<UFieldGroup label="Tabla" v-if="selectedDatabase">
|
|
<USelectMenu
|
|
v-model="selectedTable"
|
|
:items="tableOptions"
|
|
placeholder="Seleccionar tabla"
|
|
:loading="loadingTables"
|
|
:disabled="loadingTables || !selectedDatabase"
|
|
/>
|
|
</UFieldGroup>
|
|
|
|
<!-- Limit Selector -->
|
|
<UFieldGroup label="Límite de registros" v-if="selectedTable">
|
|
<USelectMenu
|
|
v-model="selectedLimit"
|
|
:items="limitOptions"
|
|
placeholder="Seleccionar límite"
|
|
/>
|
|
</UFieldGroup>
|
|
|
|
<!-- Load Data Button -->
|
|
<UButton
|
|
v-if="selectedTable"
|
|
:loading="loadingData"
|
|
:disabled="!selectedTable"
|
|
@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 cargados{{ hasMoreData ? ` (limitado a ${selectedLimit})` : '' }}
|
|
</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
|
|
:rows="paginatedData"
|
|
:columns="displayColumns"
|
|
:loading="loadingData"
|
|
/>
|
|
|
|
<!-- Pagination -->
|
|
<div class="flex items-center justify-between px-4 py-3.5">
|
|
<div class="text-sm text-muted">
|
|
Mostrando {{ startRow }} a {{ endRow }} de {{ filteredData.length }} filas{{ filteredData.length !== tableData.length ? ` (filtradas de ${tableData.length} totales)` : '' }}
|
|
</div>
|
|
<UPagination
|
|
v-if="filteredData.length > rowsPerPage"
|
|
v-model="page"
|
|
:page-count="rowsPerPage"
|
|
:total="filteredData.length"
|
|
:max="5"
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</UCard>
|
|
|
|
<!-- Empty State -->
|
|
<UCard v-else-if="selectedTable" 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'
|
|
})
|
|
|
|
// Get query params from URL
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
// State
|
|
const selectedDatabase = ref<any | null>(null)
|
|
const selectedTable = ref<any | null>(null)
|
|
const selectedLimit = ref(100)
|
|
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 page = ref(1)
|
|
const rowsPerPage = ref(50)
|
|
|
|
const databases = ref<any[]>([])
|
|
const tables = ref<any[]>([])
|
|
const tableData = ref<any[]>([])
|
|
const visibleColumns = ref<string[]>([])
|
|
|
|
// Track if we should auto-load data from URL params
|
|
const shouldAutoLoad = ref(false)
|
|
|
|
// Limit options
|
|
const limitOptions = [10, 50, 100, 500, 1000, 5000, 10000]
|
|
|
|
// Track if there might be more data
|
|
const hasMoreData = computed(() => tableData.value.length >= selectedLimit.value)
|
|
|
|
// Computed
|
|
const databaseOptions = computed(() => {
|
|
return databases.value.map(db => db.name)
|
|
})
|
|
|
|
const tableOptions = computed(() => {
|
|
return tables.value.map(table => table.display_name || table.name)
|
|
})
|
|
|
|
const selectedDatabaseId = computed(() => {
|
|
if (!selectedDatabase.value) return null
|
|
const db = databases.value.find(d => d.name === selectedDatabase.value)
|
|
return db?.id
|
|
})
|
|
|
|
const selectedTableId = computed(() => {
|
|
if (!selectedTable.value) return null
|
|
const table = tables.value.find(t => (t.display_name || t.name) === selectedTable.value)
|
|
return table?.id
|
|
})
|
|
|
|
const selectedTableName = computed(() => {
|
|
return selectedTable.value || ''
|
|
})
|
|
|
|
const tableColumns = computed(() => {
|
|
if (!tableData.value || tableData.value.length === 0) {
|
|
console.log('📊 tableColumns: No data')
|
|
return []
|
|
}
|
|
|
|
const firstRow = tableData.value[0]
|
|
const cols = Object.keys(firstRow).map(key => ({
|
|
key,
|
|
label: upperFirst(key)
|
|
}))
|
|
console.log('📊 tableColumns computed:', cols.length, 'columns')
|
|
return cols
|
|
})
|
|
|
|
const displayColumns = computed(() => {
|
|
const filtered = tableColumns.value
|
|
.filter(col => visibleColumns.value.includes(col.key))
|
|
.map(col => ({
|
|
key: col.key,
|
|
label: col.label
|
|
}))
|
|
console.log('📊 displayColumns computed:', filtered.length, 'visible columns')
|
|
return filtered
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|
|
})
|
|
|
|
// Paginated data
|
|
const paginatedData = computed(() => {
|
|
const start = (page.value - 1) * rowsPerPage.value
|
|
const end = start + rowsPerPage.value
|
|
const paginated = filteredData.value.slice(start, end)
|
|
console.log('📊 paginatedData computed:', paginated.length, 'rows for page', page.value)
|
|
return paginated
|
|
})
|
|
|
|
// Pagination helpers
|
|
const startRow = computed(() => {
|
|
if (filteredData.value.length === 0) return 0
|
|
return (page.value - 1) * rowsPerPage.value + 1
|
|
})
|
|
|
|
const endRow = computed(() => {
|
|
const end = page.value * rowsPerPage.value
|
|
return Math.min(end, filteredData.value.length)
|
|
})
|
|
|
|
// Methods
|
|
async function loadDatabases() {
|
|
loadingDatabases.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
const response = await $fetch('/api/metabase/databases')
|
|
databases.value = response as any[]
|
|
|
|
// Check if we have a database from URL params
|
|
const dbParam = route.query.db as string
|
|
|
|
if (dbParam && databases.value.length > 0) {
|
|
// Try to find database by name
|
|
const db = databases.value.find(d => d.name === dbParam)
|
|
if (db) {
|
|
selectedDatabase.value = db.name
|
|
shouldAutoLoad.value = true
|
|
} else {
|
|
// If not found, auto-select first database
|
|
selectedDatabase.value = databases.value[0].name
|
|
}
|
|
} else if (databases.value.length > 0) {
|
|
// Auto-select first database if no param
|
|
selectedDatabase.value = databases.value[0].name
|
|
}
|
|
} 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 (!selectedDatabase.value || !selectedDatabaseId.value) return
|
|
|
|
loadingTables.value = true
|
|
error.value = null
|
|
selectedTable.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 || []
|
|
|
|
// Check if we have a table from URL params
|
|
const tableParam = route.query.table as string
|
|
|
|
if (tableParam && tables.value.length > 0 && shouldAutoLoad.value) {
|
|
// Try to find table by name or display_name
|
|
const table = tables.value.find(t =>
|
|
(t.display_name || t.name) === tableParam ||
|
|
t.name === tableParam
|
|
)
|
|
|
|
if (table) {
|
|
selectedTable.value = table.display_name || table.name
|
|
// Load data automatically after table is selected
|
|
await nextTick()
|
|
await loadTableData()
|
|
}
|
|
|
|
// Reset auto-load flag
|
|
shouldAutoLoad.value = false
|
|
}
|
|
} 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: selectedLimit.value
|
|
}
|
|
}
|
|
})
|
|
|
|
// 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)
|
|
console.log('📊 Columns extracted:', columns)
|
|
|
|
tableData.value = result.data.rows.map((row: any[]) => {
|
|
const obj: any = {}
|
|
columns.forEach((col: string, index: number) => {
|
|
obj[col] = row[index]
|
|
})
|
|
return obj
|
|
})
|
|
|
|
console.log('📊 Table data transformed:', tableData.value.length, 'rows')
|
|
console.log('📊 First row:', tableData.value[0])
|
|
|
|
// Initialize visible columns
|
|
visibleColumns.value = columns
|
|
console.log('📊 Visible columns set:', visibleColumns.value)
|
|
} else {
|
|
console.log('❌ No data structure found:', result)
|
|
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(selectedDatabase, (newDb) => {
|
|
loadTables()
|
|
|
|
// Update URL query params when database changes
|
|
if (newDb && !shouldAutoLoad.value) {
|
|
router.push({
|
|
query: {
|
|
...route.query,
|
|
db: newDb,
|
|
table: undefined // Clear table when db changes
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
watch(selectedTable, (newTable) => {
|
|
// Update URL query params when table changes
|
|
if (newTable && !shouldAutoLoad.value) {
|
|
router.push({
|
|
query: {
|
|
...route.query,
|
|
table: newTable
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
// Reset page when search changes
|
|
watch(searchQuery, () => {
|
|
page.value = 1
|
|
})
|
|
|
|
// Reset page when data loads
|
|
watch(tableData, () => {
|
|
page.value = 1
|
|
})
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
loadDatabases()
|
|
})
|
|
</script>
|