327 lines
8.7 KiB
Vue
327 lines
8.7 KiB
Vue
<template>
|
|
<div class="mx-auto flex max-w-6xl flex-col gap-6 px-4 py-10">
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex flex-col gap-2">
|
|
<h1 class="text-2xl font-semibold">Visor de datos</h1>
|
|
<p class="text-sm text-slate-300">
|
|
Selecciona una tabla e introduce filtros para consultar los datos en modo solo lectura.
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="grid gap-4 lg:grid-cols-4">
|
|
<UFormGroup label="Tabla" name="table">
|
|
<USelectMenu
|
|
v-model="filters.table"
|
|
:options="tableOptions"
|
|
value-attribute="value"
|
|
option-attribute="label"
|
|
placeholder="Selecciona una tabla"
|
|
/>
|
|
</UFormGroup>
|
|
|
|
<UFormGroup label="ID" name="id">
|
|
<UInput v-model="filters.id" placeholder="Filtrar por ID" />
|
|
</UFormGroup>
|
|
|
|
<UFormGroup label="Fecha desde" name="from">
|
|
<UInput v-model="filters.from" type="date" />
|
|
</UFormGroup>
|
|
|
|
<UFormGroup label="Fecha hasta" name="to">
|
|
<UInput v-model="filters.to" type="date" />
|
|
</UFormGroup>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div class="text-xs text-slate-400">
|
|
Para filtros avanzados puedes codificar un objeto JSON en base64 e invocarlo mediante la ruta
|
|
<code class="rounded bg-slate-800 px-2 py-1">/api/data/{{ filters.table }}/[query]</code>.
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<UButton color="gray" variant="soft" @click="resetFilters" :disabled="loading">
|
|
Limpiar filtros
|
|
</UButton>
|
|
<UButton @click="refresh" :loading="loading" :disabled="!filters.table">
|
|
Consultar datos
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
|
|
<div v-if="errorMessage" class="rounded-lg border border-red-500 bg-red-500/10 p-4 text-sm text-red-200">
|
|
{{ errorMessage }}
|
|
</div>
|
|
|
|
<div v-if="activeMetadata" class="grid gap-4 lg:grid-cols-2">
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">Resumen de {{ activeMetadata.table }}</h2>
|
|
<UBadge color="primary">{{ activeMetadata.rowCount }} registros</UBadge>
|
|
</div>
|
|
</template>
|
|
<dl class="grid grid-cols-2 gap-2 text-sm">
|
|
<div>
|
|
<dt class="text-slate-400">Clave primaria</dt>
|
|
<dd class="font-medium">{{ activeMetadata.primaryKey }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-slate-400">Última consulta</dt>
|
|
<dd class="font-medium">{{ formatDate(activeMetadata.lastRefreshed) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-slate-400">Tamaño aprox.</dt>
|
|
<dd class="font-medium">{{ formatSize(activeMetadata.approxSizeBytes) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-slate-400">Rango de creación</dt>
|
|
<dd class="font-medium">
|
|
{{ formatDate(activeMetadata.createdAtRange?.from) }}
|
|
—
|
|
{{ formatDate(activeMetadata.createdAtRange?.to) }}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
|
|
<template #footer>
|
|
<div class="text-xs text-slate-400">
|
|
Columnas detectadas: {{ activeMetadata.columns.join(', ') }}
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
|
|
<UCard v-if="activeMetadata.sampleRow">
|
|
<template #header>
|
|
<h2 class="text-lg font-semibold">Registro de ejemplo</h2>
|
|
</template>
|
|
<pre class="overflow-auto rounded bg-slate-900 p-4 text-sm">{{ formatSample(activeMetadata.sampleRow) }}</pre>
|
|
</UCard>
|
|
</div>
|
|
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<h2 class="text-lg font-semibold">Datos</h2>
|
|
<UBadge v-if="tableData" color="gray">
|
|
{{ tableData.length }} registros visibles
|
|
</UBadge>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="loading" class="flex items-center justify-center py-10">
|
|
<ULoadingIndicator size="lg" />
|
|
</div>
|
|
<div v-else-if="tableData.length === 0" class="py-10 text-center text-sm text-slate-400">
|
|
No hay datos que coincidan con los filtros actuales.
|
|
</div>
|
|
<div v-else class="overflow-auto">
|
|
<table class="min-w-full divide-y divide-slate-800 text-sm">
|
|
<thead class="bg-slate-900/60">
|
|
<tr>
|
|
<th v-for="column in visibleColumns" :key="column" class="px-4 py-2 text-left font-semibold">
|
|
{{ column }}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-slate-800">
|
|
<tr v-for="(row, index) in tableData" :key="index" class="hover:bg-slate-900/40">
|
|
<td v-for="column in visibleColumns" :key="column" class="px-4 py-2">
|
|
{{ formatCell(row[column]) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</UCard>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|
|
|
const loading = ref(false)
|
|
const errorMessage = ref<string | null>(null)
|
|
const tableData = ref<Record<string, unknown>[]>([])
|
|
const activeMetadata = ref<any | null>(null)
|
|
const availableMetadata = ref<any[]>([])
|
|
|
|
const filters = reactive({
|
|
table: '',
|
|
id: '',
|
|
from: '',
|
|
to: ''
|
|
})
|
|
|
|
const tableOptions = computed(() =>
|
|
availableMetadata.value.map((meta) => ({
|
|
label: `${meta.table} (${meta.rowCount})`,
|
|
value: meta.table
|
|
}))
|
|
)
|
|
|
|
const visibleColumns = computed(() => (tableData.value[0] ? Object.keys(tableData.value[0]) : []))
|
|
|
|
onMounted(async () => {
|
|
await loadAvailableMetadata()
|
|
})
|
|
|
|
watch(
|
|
() => filters.table,
|
|
async (value) => {
|
|
if (!value) {
|
|
tableData.value = []
|
|
activeMetadata.value = null
|
|
return
|
|
}
|
|
|
|
await refresh()
|
|
}
|
|
)
|
|
|
|
async function loadAvailableMetadata() {
|
|
try {
|
|
const metadata = await $fetch('/api/metadata')
|
|
availableMetadata.value = Array.isArray(metadata) ? metadata : []
|
|
} catch (error) {
|
|
errorMessage.value = extractErrorMessage(error)
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
if (!filters.table) {
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
errorMessage.value = null
|
|
|
|
try {
|
|
const [metadata, data] = await Promise.all([
|
|
$fetch(`/api/metadata/${filters.table}`),
|
|
$fetch(`/api/data/${filters.table}`, {
|
|
query: buildQueryParams()
|
|
})
|
|
])
|
|
|
|
activeMetadata.value = metadata
|
|
const metadataIndex = availableMetadata.value.findIndex((item) => item.table === metadata.table)
|
|
if (metadataIndex >= 0) {
|
|
availableMetadata.value[metadataIndex] = metadata
|
|
}
|
|
tableData.value = data?.records ?? []
|
|
} catch (error) {
|
|
errorMessage.value = extractErrorMessage(error)
|
|
tableData.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function buildQueryParams() {
|
|
const params: Record<string, string> = { limit: '100' }
|
|
|
|
if (filters.id) {
|
|
params.id = filters.id.trim()
|
|
}
|
|
|
|
if (filters.from) {
|
|
params.created_from = filters.from
|
|
}
|
|
|
|
if (filters.to) {
|
|
params.created_to = filters.to
|
|
}
|
|
|
|
return params
|
|
}
|
|
|
|
function resetFilters() {
|
|
filters.id = ''
|
|
filters.from = ''
|
|
filters.to = ''
|
|
|
|
if (filters.table) {
|
|
refresh()
|
|
}
|
|
}
|
|
|
|
function extractErrorMessage(error: unknown) {
|
|
if (error && typeof error === 'object' && 'statusMessage' in error) {
|
|
return String((error as any).statusMessage)
|
|
}
|
|
|
|
if (error instanceof Error) {
|
|
return error.message
|
|
}
|
|
|
|
return 'Ocurrió un error inesperado al consultar los datos.'
|
|
}
|
|
|
|
function formatSize(bytes: number | null) {
|
|
if (!bytes) {
|
|
return 'No disponible'
|
|
}
|
|
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`
|
|
}
|
|
|
|
const units = ['KB', 'MB', 'GB']
|
|
let size = bytes / 1024
|
|
let unitIndex = 0
|
|
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024
|
|
unitIndex += 1
|
|
}
|
|
|
|
return `${size.toFixed(1)} ${units[unitIndex]}`
|
|
}
|
|
|
|
function formatDate(value: string | null | undefined) {
|
|
if (!value) {
|
|
return '—'
|
|
}
|
|
|
|
const date = new Date(value)
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value
|
|
}
|
|
|
|
return date.toLocaleString()
|
|
}
|
|
|
|
function formatCell(value: unknown) {
|
|
if (value === null || value === undefined) {
|
|
return '—'
|
|
}
|
|
|
|
if (value instanceof Date) {
|
|
return value.toISOString()
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch (error) {
|
|
return '[objeto]'
|
|
}
|
|
}
|
|
|
|
return String(value)
|
|
}
|
|
|
|
function formatSample(value: unknown) {
|
|
try {
|
|
return JSON.stringify(value, null, 2)
|
|
} catch (error) {
|
|
return String(value)
|
|
}
|
|
}
|
|
</script>
|