Refine SPA shell and query builder UI
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtPage />
|
||||
</div>
|
||||
<UApp>
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
@@ -3,120 +3,221 @@
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-2xl font-semibold">Visor de datos</h1>
|
||||
<h1 class="text-2xl font-semibold">Constructor de consultas</h1>
|
||||
<p class="text-sm text-slate-300">
|
||||
Selecciona una tabla e introduce filtros para consultar los datos en modo solo lectura.
|
||||
Arma una solicitud a los endpoints del backend y visualiza los resultados 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>
|
||||
<form class="flex flex-col gap-6" @submit.prevent="executeRequest">
|
||||
<div class="grid gap-4 lg:grid-cols-4">
|
||||
<UFormGroup label="Tipo de consulta" name="type">
|
||||
<USelectMenu
|
||||
v-model="request.type"
|
||||
:options="requestTypeOptions"
|
||||
option-attribute="label"
|
||||
value-attribute="value"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="ID" name="id">
|
||||
<UInput v-model="filters.id" placeholder="Filtrar por ID" />
|
||||
</UFormGroup>
|
||||
<UFormGroup label="Ámbito" name="scope">
|
||||
<USelectMenu
|
||||
v-model="request.scope"
|
||||
:options="scopeOptions"
|
||||
option-attribute="label"
|
||||
value-attribute="value"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Fecha desde" name="from">
|
||||
<UInput v-model="filters.from" type="date" />
|
||||
</UFormGroup>
|
||||
<UFormGroup v-if="requiresTable" label="Tabla" name="table">
|
||||
<USelectMenu
|
||||
v-model="request.table"
|
||||
:options="tableOptions"
|
||||
option-attribute="label"
|
||||
value-attribute="value"
|
||||
placeholder="Selecciona una tabla"
|
||||
/>
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup label="Fecha hasta" name="to">
|
||||
<UInput v-model="filters.to" type="date" />
|
||||
</UFormGroup>
|
||||
</div>
|
||||
<UFormGroup v-if="showsLimit" label="Límite" name="limit">
|
||||
<UInput v-model.number="request.limit" type="number" min="1" max="500" />
|
||||
</UFormGroup>
|
||||
|
||||
<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>
|
||||
<UFormGroup v-if="requiresRecordId" label="ID del registro" name="recordId">
|
||||
<UInput v-model="request.recordId" placeholder="Introduce el ID exacto" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup v-if="showsIdFilter" label="Filtrar por ID" name="filterId">
|
||||
<UInput v-model="request.filterId" placeholder="Opcional" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup v-if="showsDateFilters" label="Fecha desde" name="createdFrom">
|
||||
<UInput v-model="request.createdFrom" type="date" />
|
||||
</UFormGroup>
|
||||
|
||||
<UFormGroup v-if="showsDateFilters" label="Fecha hasta" name="createdTo">
|
||||
<UInput v-model="request.createdTo" type="date" />
|
||||
</UFormGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showQueryJson" class="space-y-2">
|
||||
<UFormGroup label="Consulta avanzada (JSON)">
|
||||
<UTextarea
|
||||
v-model="request.queryJson"
|
||||
:rows="5"
|
||||
placeholder='{ "filters": [{ "field": "estado", "operator": "eq", "value": "activo" }] }'
|
||||
/>
|
||||
</UFormGroup>
|
||||
<p v-if="queryState.error" class="text-sm text-red-300">{{ queryState.error }}</p>
|
||||
<p v-else-if="queryState.encoded" class="text-xs text-slate-400">
|
||||
Segmento codificado: <code class="rounded bg-slate-800 px-2 py-1">{{ queryState.encoded }}</code>
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
Se codifica automáticamente en base64-url para construir la ruta <code>/api/data/{{ request.table || ':tabla' }}/{{
|
||||
queryState.encoded || ':query'
|
||||
}}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UAlert color="primary" variant="soft">
|
||||
<template #title>Solicitud generada</template>
|
||||
<template #description>
|
||||
<code class="break-all text-sm">GET {{ requestPreview }}</code>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton color="gray" variant="soft" @click="resetForm" :disabled="loading">
|
||||
Limpiar
|
||||
</UButton>
|
||||
<UButton type="submit" :loading="loading">
|
||||
Ejecutar consulta
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
<section v-if="hasMetadataResponse" class="flex flex-col gap-4">
|
||||
<div v-if="metadataCollection.length" class="grid gap-4 md:grid-cols-2">
|
||||
<UCard v-for="meta in metadataCollection" :key="meta.table">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold">Tabla {{ meta.table }}</h2>
|
||||
<UBadge color="primary">{{ meta.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">{{ meta.primaryKey }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-400">Tamaño aprox.</dt>
|
||||
<dd class="font-medium">{{ formatSize(meta.approxSizeBytes) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-400">Creación desde</dt>
|
||||
<dd class="font-medium">{{ formatDate(meta.createdAtRange?.from) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-slate-400">Creación hasta</dt>
|
||||
<dd class="font-medium">{{ formatDate(meta.createdAtRange?.to) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<template #footer>
|
||||
<div class="text-xs text-slate-400">
|
||||
Columnas detectadas: {{ meta.columns.join(', ') }}
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeMetadata" class="grid gap-4 md: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 v-if="metadataRecord">
|
||||
<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>
|
||||
<h2 class="text-lg font-semibold">Metadata del registro {{ metadataRecord.id }}</h2>
|
||||
</template>
|
||||
<pre class="overflow-auto rounded bg-slate-900 p-4 text-sm">{{ formatSample(metadataRecord.metadata) }}</pre>
|
||||
</UCard>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<UCard v-if="request.type === 'data' || hasDataResponse">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold">Datos</h2>
|
||||
<UBadge v-if="tableData" color="gray">
|
||||
{{ tableData.length }} registros visibles
|
||||
</UBadge>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-slate-300">
|
||||
<template v-if="dataStats">
|
||||
<UBadge color="primary">
|
||||
{{ dataStats.table }}: {{ dataStats.count }} registros (límite {{ dataStats.limit ?? 's/d' }})
|
||||
</UBadge>
|
||||
</template>
|
||||
<template v-else-if="dataStatsCollection.length">
|
||||
<UBadge v-for="item in dataStatsCollection" :key="item.table" color="gray">
|
||||
{{ item.table }}: {{ item.count }} registros (límite {{ item.limit ?? 's/d' }})
|
||||
</UBadge>
|
||||
</template>
|
||||
<UBadge v-else-if="tableData.length" color="gray">
|
||||
{{ tableData.length }} registros visibles
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ULoadingIndicator size="lg" />
|
||||
</div>
|
||||
<div v-else-if="!hasDataResponse" class="py-10 text-center text-sm text-slate-400">
|
||||
Ejecuta una consulta de datos para ver resultados aquí.
|
||||
</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.
|
||||
No se encontraron registros para los criterios seleccionados.
|
||||
</div>
|
||||
<div v-else class="overflow-auto">
|
||||
<table class="min-w-full divide-y divide-slate-800 text-sm">
|
||||
@@ -137,62 +238,356 @@
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<UCard v-if="rawResponse">
|
||||
<template #header>
|
||||
<h2 class="text-lg font-semibold">Respuesta cruda (JSON)</h2>
|
||||
</template>
|
||||
<pre class="max-h-96 overflow-auto rounded bg-slate-900 p-4 text-sm">{{ formatSample(rawResponse) }}</pre>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
type RequestType = 'data' | 'metadata'
|
||||
type MetadataScope = 'all' | 'table' | 'record'
|
||||
type DataScope = 'all' | 'table' | 'record' | 'query'
|
||||
type RequestScope = MetadataScope | DataScope
|
||||
|
||||
interface Option<T extends string> {
|
||||
label: string
|
||||
value: T
|
||||
}
|
||||
|
||||
const requestTypeOptions: Option<RequestType>[] = [
|
||||
{ label: 'Datos', value: 'data' },
|
||||
{ label: 'Metadatos', value: 'metadata' }
|
||||
]
|
||||
|
||||
const metadataScopeOptions: Option<MetadataScope>[] = [
|
||||
{ label: 'Todas las tablas', value: 'all' },
|
||||
{ label: 'Por tabla', value: 'table' },
|
||||
{ label: 'Registro específico', value: 'record' }
|
||||
]
|
||||
|
||||
const dataScopeOptions: Option<DataScope>[] = [
|
||||
{ label: 'Todas las tablas', value: 'all' },
|
||||
{ label: 'Por tabla', value: 'table' },
|
||||
{ label: 'Registro específico', value: 'record' },
|
||||
{ label: 'Consulta avanzada', value: 'query' }
|
||||
]
|
||||
|
||||
const request = reactive<{
|
||||
type: RequestType
|
||||
scope: RequestScope
|
||||
table: string
|
||||
recordId: string
|
||||
filterId: string
|
||||
createdFrom: string
|
||||
createdTo: string
|
||||
limit: number
|
||||
queryJson: string
|
||||
}>(
|
||||
{
|
||||
type: 'data',
|
||||
scope: 'table',
|
||||
table: '',
|
||||
recordId: '',
|
||||
filterId: '',
|
||||
createdFrom: '',
|
||||
createdTo: '',
|
||||
limit: 100,
|
||||
queryJson: ''
|
||||
}
|
||||
)
|
||||
|
||||
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 rawResponse = ref<unknown>(null)
|
||||
|
||||
const filters = reactive({
|
||||
table: '',
|
||||
id: '',
|
||||
from: '',
|
||||
to: ''
|
||||
const availableMetadata = ref<any[]>([])
|
||||
const metadataCollection = ref<any[]>([])
|
||||
const metadataRecord = ref<any | null>(null)
|
||||
const activeMetadata = ref<any | null>(null)
|
||||
|
||||
const tableData = ref<Record<string, unknown>[]>([])
|
||||
const dataStats = ref<{ table: string; count: number; limit?: number | null } | null>(null)
|
||||
const dataStatsCollection = ref<{ table: string; count: number; limit?: number | null }[]>([])
|
||||
|
||||
const hasDataResponse = ref(false)
|
||||
const hasMetadataResponse = ref(false)
|
||||
|
||||
const scopeOptions = computed(() => (request.type === 'metadata' ? metadataScopeOptions : dataScopeOptions))
|
||||
|
||||
const requiresTable = computed(() => {
|
||||
if (request.type === 'metadata') {
|
||||
return request.scope !== 'all'
|
||||
}
|
||||
|
||||
return request.scope !== 'all'
|
||||
})
|
||||
|
||||
const requiresRecordId = computed(() => request.scope === 'record')
|
||||
|
||||
const showsLimit = computed(
|
||||
() => request.type === 'data' && (request.scope === 'all' || request.scope === 'table' || request.scope === 'query')
|
||||
)
|
||||
|
||||
const showsDateFilters = computed(() => request.type === 'data' && request.scope === 'table')
|
||||
const showsIdFilter = computed(() => request.type === 'data' && request.scope === 'table')
|
||||
const showQueryJson = computed(() => request.type === 'data' && request.scope === 'query')
|
||||
|
||||
const tableOptions = computed(() =>
|
||||
availableMetadata.value.map((meta) => ({
|
||||
label: `${meta.table} (${meta.rowCount})`,
|
||||
label: `${meta.table} (${meta.rowCount ?? '—'})`,
|
||||
value: meta.table
|
||||
}))
|
||||
)
|
||||
|
||||
const visibleColumns = computed(() => (tableData.value[0] ? Object.keys(tableData.value[0]) : []))
|
||||
|
||||
const queryState = computed(() => {
|
||||
if (!showQueryJson.value) {
|
||||
return { encoded: '', error: null as string | null }
|
||||
}
|
||||
|
||||
const trimmed = request.queryJson.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return { encoded: '', error: null as string | null }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed)
|
||||
const normalized = JSON.stringify(parsed)
|
||||
return { encoded: encodeBase64Url(normalized), error: null as string | null }
|
||||
} catch (error) {
|
||||
return { encoded: '', error: 'El JSON proporcionado no es válido.' }
|
||||
}
|
||||
})
|
||||
|
||||
const requestPreview = computed(() => {
|
||||
const base = request.type === 'metadata' ? '/api/metadata' : '/api/data'
|
||||
let path = base
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (request.type === 'metadata') {
|
||||
if (request.scope === 'table') {
|
||||
path += `/${request.table || ':tabla'}`
|
||||
} else if (request.scope === 'record') {
|
||||
path += `/${request.table || ':tabla'}/${request.recordId || ':id'}`
|
||||
}
|
||||
} else {
|
||||
const limit = sanitizeLimit(request.limit)
|
||||
|
||||
if (request.scope === 'all') {
|
||||
params.set('limit', String(limit))
|
||||
} else if (request.scope === 'table') {
|
||||
path += `/${request.table || ':tabla'}`
|
||||
params.set('limit', String(limit))
|
||||
if (request.filterId) params.set('id', request.filterId.trim())
|
||||
if (request.createdFrom) params.set('created_from', request.createdFrom)
|
||||
if (request.createdTo) params.set('created_to', request.createdTo)
|
||||
} else if (request.scope === 'record') {
|
||||
path += `/${request.table || ':tabla'}/${request.recordId || ':id'}`
|
||||
} else if (request.scope === 'query') {
|
||||
const segment = queryState.value.encoded || ':query-base64'
|
||||
path += `/${request.table || ':tabla'}/${segment}`
|
||||
params.set('limit', String(limit))
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
return queryString ? `${path}?${queryString}` : path
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAvailableMetadata()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => filters.table,
|
||||
async (value) => {
|
||||
if (!value) {
|
||||
tableData.value = []
|
||||
activeMetadata.value = null
|
||||
return
|
||||
() => request.type,
|
||||
(type) => {
|
||||
request.scope = type === 'metadata' ? metadataScopeOptions[0].value : dataScopeOptions[0].value
|
||||
request.recordId = ''
|
||||
request.filterId = ''
|
||||
request.createdFrom = ''
|
||||
request.createdTo = ''
|
||||
request.queryJson = ''
|
||||
clearResults()
|
||||
|
||||
if (requiresTable.value && !request.table && availableMetadata.value.length > 0) {
|
||||
request.table = availableMetadata.value[0].table
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => request.scope,
|
||||
() => {
|
||||
if (!requiresTable.value) {
|
||||
request.table = ''
|
||||
} else if (!request.table && availableMetadata.value.length > 0) {
|
||||
request.table = availableMetadata.value[0].table
|
||||
}
|
||||
|
||||
await refresh()
|
||||
if (!requiresRecordId.value) {
|
||||
request.recordId = ''
|
||||
}
|
||||
|
||||
if (!showsIdFilter.value) {
|
||||
request.filterId = ''
|
||||
}
|
||||
|
||||
if (!showsDateFilters.value) {
|
||||
request.createdFrom = ''
|
||||
request.createdTo = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function loadAvailableMetadata() {
|
||||
try {
|
||||
const metadata = await $fetch('/api/metadata')
|
||||
availableMetadata.value = Array.isArray(metadata) ? metadata : []
|
||||
if (Array.isArray(metadata)) {
|
||||
availableMetadata.value = metadata
|
||||
if (!request.table && metadata.length > 0 && requiresTable.value) {
|
||||
request.table = metadata[0].table
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = extractErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!filters.table) {
|
||||
function sanitizeLimit(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return 100
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(500, Math.trunc(value)))
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: string) {
|
||||
if (typeof globalThis !== 'undefined' && typeof globalThis.btoa === 'function') {
|
||||
const encoded = globalThis.btoa(
|
||||
encodeURIComponent(value).replace(/%([0-9A-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
)
|
||||
|
||||
return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
// @ts-ignore Buffer is available en entornos node
|
||||
return Buffer.from(value, 'utf-8').toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function clearResults() {
|
||||
metadataCollection.value = []
|
||||
metadataRecord.value = null
|
||||
activeMetadata.value = null
|
||||
tableData.value = []
|
||||
dataStats.value = null
|
||||
dataStatsCollection.value = []
|
||||
rawResponse.value = null
|
||||
hasDataResponse.value = false
|
||||
hasMetadataResponse.value = false
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
request.scope = request.type === 'metadata' ? metadataScopeOptions[0].value : dataScopeOptions[0].value
|
||||
request.table = availableMetadata.value[0]?.table ?? ''
|
||||
request.recordId = ''
|
||||
request.filterId = ''
|
||||
request.createdFrom = ''
|
||||
request.createdTo = ''
|
||||
request.limit = 100
|
||||
request.queryJson = ''
|
||||
clearResults()
|
||||
}
|
||||
|
||||
function buildExecutableRequest() {
|
||||
const base = request.type === 'metadata' ? '/api/metadata' : '/api/data'
|
||||
let path = base
|
||||
const query: Record<string, string> = {}
|
||||
|
||||
if (request.type === 'metadata') {
|
||||
if (request.scope === 'all') {
|
||||
return { url: path, query, error: null as string | null }
|
||||
}
|
||||
|
||||
if (!request.table) {
|
||||
return { url: '', query, error: 'Selecciona una tabla antes de ejecutar la consulta.' }
|
||||
}
|
||||
|
||||
path += `/${request.table}`
|
||||
|
||||
if (request.scope === 'record') {
|
||||
if (!request.recordId.trim()) {
|
||||
return { url: '', query, error: 'Introduce el ID del registro que deseas consultar.' }
|
||||
}
|
||||
|
||||
path += `/${request.recordId.trim()}`
|
||||
}
|
||||
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
// Consultas de datos
|
||||
if (request.scope === 'all') {
|
||||
query.limit = String(sanitizeLimit(request.limit))
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
if (!request.table) {
|
||||
return { url: '', query, error: 'Selecciona una tabla antes de ejecutar la consulta.' }
|
||||
}
|
||||
|
||||
if (request.scope === 'table') {
|
||||
path += `/${request.table}`
|
||||
query.limit = String(sanitizeLimit(request.limit))
|
||||
if (request.filterId) {
|
||||
query.id = request.filterId.trim()
|
||||
}
|
||||
if (request.createdFrom) {
|
||||
query.created_from = request.createdFrom
|
||||
}
|
||||
if (request.createdTo) {
|
||||
query.created_to = request.createdTo
|
||||
}
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
if (request.scope === 'record') {
|
||||
if (!request.recordId.trim()) {
|
||||
return { url: '', query, error: 'Introduce el ID del registro que deseas consultar.' }
|
||||
}
|
||||
|
||||
path += `/${request.table}/${request.recordId.trim()}`
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
// Consulta avanzada
|
||||
if (!request.queryJson.trim()) {
|
||||
return { url: '', query, error: 'Introduce un JSON para la consulta avanzada.' }
|
||||
}
|
||||
|
||||
if (queryState.value.error) {
|
||||
return { url: '', query, error: queryState.value.error }
|
||||
}
|
||||
|
||||
path += `/${request.table}/${queryState.value.encoded}`
|
||||
query.limit = String(sanitizeLimit(request.limit))
|
||||
|
||||
return { url: path, query, error: null }
|
||||
}
|
||||
|
||||
async function executeRequest() {
|
||||
const requestConfig = buildExecutableRequest()
|
||||
|
||||
if (requestConfig.error) {
|
||||
errorMessage.value = requestConfig.error
|
||||
return
|
||||
}
|
||||
|
||||
@@ -200,58 +595,110 @@ async function refresh() {
|
||||
errorMessage.value = null
|
||||
|
||||
try {
|
||||
const [metadata, data] = await Promise.all([
|
||||
$fetch(`/api/metadata/${filters.table}`),
|
||||
$fetch(`/api/data/${filters.table}`, {
|
||||
query: buildQueryParams()
|
||||
})
|
||||
])
|
||||
const response = await $fetch(requestConfig.url, {
|
||||
query: requestConfig.query
|
||||
})
|
||||
|
||||
activeMetadata.value = metadata
|
||||
const metadataIndex = availableMetadata.value.findIndex((item) => item.table === metadata.table)
|
||||
if (metadataIndex >= 0) {
|
||||
availableMetadata.value[metadataIndex] = metadata
|
||||
}
|
||||
tableData.value = data?.records ?? []
|
||||
processResponse(response)
|
||||
rawResponse.value = response
|
||||
} catch (error) {
|
||||
clearResults()
|
||||
errorMessage.value = extractErrorMessage(error)
|
||||
tableData.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryParams() {
|
||||
const params: Record<string, string> = { limit: '100' }
|
||||
function processResponse(response: unknown) {
|
||||
metadataCollection.value = []
|
||||
metadataRecord.value = null
|
||||
activeMetadata.value = null
|
||||
tableData.value = []
|
||||
dataStats.value = null
|
||||
dataStatsCollection.value = []
|
||||
|
||||
if (filters.id) {
|
||||
params.id = filters.id.trim()
|
||||
if (request.type === 'metadata') {
|
||||
hasMetadataResponse.value = true
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
metadataCollection.value = response
|
||||
availableMetadata.value = response
|
||||
} else if (request.scope === 'record' && response && typeof response === 'object') {
|
||||
metadataRecord.value = response
|
||||
} else {
|
||||
activeMetadata.value = response
|
||||
|
||||
if (response && typeof response === 'object' && 'table' in response) {
|
||||
const index = availableMetadata.value.findIndex((item) => item.table === (response as any).table)
|
||||
if (index >= 0) {
|
||||
availableMetadata.value[index] = response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (filters.from) {
|
||||
params.created_from = filters.from
|
||||
hasDataResponse.value = true
|
||||
|
||||
if (request.scope === 'all' && Array.isArray(response)) {
|
||||
const datasets = response as Array<{
|
||||
table: string
|
||||
count?: number | null
|
||||
limit?: number | null
|
||||
records?: Record<string, unknown>[]
|
||||
}>
|
||||
|
||||
dataStatsCollection.value = datasets.map((item) => ({
|
||||
table: item.table,
|
||||
count: item.count ?? item.records?.length ?? 0,
|
||||
limit: item.limit ?? null
|
||||
}))
|
||||
|
||||
tableData.value = datasets.flatMap((item) => {
|
||||
const rows = Array.isArray(item.records) ? item.records : []
|
||||
return rows.map((row) => ({ __tabla: item.table, ...row }))
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (filters.to) {
|
||||
params.created_to = filters.to
|
||||
if (request.scope === 'record') {
|
||||
tableData.value = response ? [response as Record<string, unknown>] : []
|
||||
dataStats.value = {
|
||||
table: request.table,
|
||||
count: tableData.value.length,
|
||||
limit: null
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
if (response && typeof response === 'object' && 'records' in response) {
|
||||
const dataset = response as {
|
||||
table: string
|
||||
count?: number | null
|
||||
limit?: number | null
|
||||
records?: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.id = ''
|
||||
filters.from = ''
|
||||
filters.to = ''
|
||||
const rows = Array.isArray(dataset.records) ? dataset.records : []
|
||||
tableData.value = rows
|
||||
dataStats.value = {
|
||||
table: dataset.table,
|
||||
count: dataset.count ?? rows.length,
|
||||
limit: dataset.limit ?? null
|
||||
}
|
||||
|
||||
if (filters.table) {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
|
||||
tableData.value = Array.isArray(response) ? (response as Record<string, unknown>[]) : []
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown) {
|
||||
if (error && typeof error === 'object' && 'statusMessage' in error) {
|
||||
return String((error as any).statusMessage)
|
||||
return String((error as { statusMessage: string }).statusMessage)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
@@ -261,7 +708,7 @@ function extractErrorMessage(error: unknown) {
|
||||
return 'Ocurrió un error inesperado al consultar los datos.'
|
||||
}
|
||||
|
||||
function formatSize(bytes: number | null) {
|
||||
function formatSize(bytes: number | null | undefined) {
|
||||
if (!bytes) {
|
||||
return 'No disponible'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user