@innertia-solutions/innertia-nuxt 0.1.54 → 0.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Observability/Dashboard.vue +105 -0
- package/components/Observability/ErrorDetail.vue +82 -0
- package/components/Observability/Errors.vue +42 -17
- package/components/Observability/Gauge.vue +21 -0
- package/components/Observability/MetricCard.vue +22 -0
- package/components/Observability/Overview.vue +71 -0
- package/components/Observability/Panel.vue +44 -0
- package/package.json +1 -1
- package/components/Observability/Database.vue +0 -59
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Dashboard de base de datos con valor: gauges (cache hit, conexiones), cards
|
|
3
|
+
// (deadlocks, rollbacks), y tablas (queries lentas, índices sin usar, tablas con
|
|
4
|
+
// más dead tuples). Cada sección pide su endpoint (lazy) y refresca cada ~10s.
|
|
5
|
+
const api = useApi()
|
|
6
|
+
const REFETCH = 10_000
|
|
7
|
+
|
|
8
|
+
const overview = useQuery({ queryKey: ['obs-db', 'overview'], queryFn: () => api.get('platform/observability/database/overview'), refetchInterval: REFETCH })
|
|
9
|
+
const connections = useQuery({ queryKey: ['obs-db', 'connections'], queryFn: () => api.get('platform/observability/database/connections'), refetchInterval: REFETCH })
|
|
10
|
+
const queries = useQuery({ queryKey: ['obs-db', 'queries'], queryFn: () => api.get('platform/observability/database/queries'), refetchInterval: REFETCH })
|
|
11
|
+
const indexes = useQuery({ queryKey: ['obs-db', 'indexes'], queryFn: () => api.get('platform/observability/database/indexes'), refetchInterval: REFETCH })
|
|
12
|
+
const tables = useQuery({ queryKey: ['obs-db', 'tables'], queryFn: () => api.get('platform/observability/database/tables'), refetchInterval: REFETCH })
|
|
13
|
+
|
|
14
|
+
const connPercent = computed(() => {
|
|
15
|
+
const d = connections.data.value
|
|
16
|
+
if (!d || d.total == null || !d.max) return null
|
|
17
|
+
return Math.round((d.total / d.max) * 100)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function fmtBytes(bytes) {
|
|
21
|
+
if (bytes == null) return '—'
|
|
22
|
+
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let n = Number(bytes), i = 0
|
|
23
|
+
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ }
|
|
24
|
+
return `${n.toFixed(1)} ${u[i]}`
|
|
25
|
+
}
|
|
26
|
+
function fmtMs(ms) { return ms == null ? '—' : `${Number(ms).toLocaleString('es-CL')} ms` }
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<template>
|
|
30
|
+
<div class="space-y-6">
|
|
31
|
+
<!-- Gauges + cards de salud -->
|
|
32
|
+
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
33
|
+
<Observability.Gauge label="Cache hit" :percent="overview.data.value?.cache_hit_ratio ?? null" />
|
|
34
|
+
<Observability.Gauge label="Conexiones" :percent="connPercent" />
|
|
35
|
+
<Observability.MetricCard label="Deadlocks" :value="overview.data.value?.deadlocks ?? '—'"
|
|
36
|
+
:tone="(overview.data.value?.deadlocks ?? 0) > 0 ? 'bad' : 'good'" />
|
|
37
|
+
<Observability.MetricCard label="Rollbacks" :value="overview.data.value?.rollbacks ?? '—'"
|
|
38
|
+
:sublabel="`Tamaño BD: ${fmtBytes(overview.data.value?.database_bytes)}`" />
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<!-- Conexiones por estado -->
|
|
42
|
+
<div v-if="connections.data.value" class="flex flex-wrap gap-2">
|
|
43
|
+
<span v-for="(n, state) in connections.data.value.by_state" :key="state"
|
|
44
|
+
class="rounded-full border border-card-line bg-card px-2.5 py-1 text-xs text-muted-foreground">
|
|
45
|
+
{{ state }}: <span class="font-semibold text-foreground">{{ n }}</span>
|
|
46
|
+
</span>
|
|
47
|
+
<span class="rounded-full border border-card-line bg-card px-2.5 py-1 text-xs text-muted-foreground">
|
|
48
|
+
total: <span class="font-semibold text-foreground">{{ connections.data.value.total }}</span> / {{ connections.data.value.max ?? '—' }}
|
|
49
|
+
</span>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Queries lentas -->
|
|
53
|
+
<section>
|
|
54
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Queries más lentas</h3>
|
|
55
|
+
<div v-if="queries.data.value && !queries.data.value.available"
|
|
56
|
+
class="rounded-card border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-400">
|
|
57
|
+
{{ queries.data.value.reason }}
|
|
58
|
+
</div>
|
|
59
|
+
<table v-else class="w-full text-sm">
|
|
60
|
+
<thead><tr class="text-left text-muted-foreground">
|
|
61
|
+
<th class="py-1 pr-4">Query</th>
|
|
62
|
+
<th class="py-1 pr-4 whitespace-nowrap">Llamadas</th>
|
|
63
|
+
<th class="py-1 pr-4 whitespace-nowrap">Media</th>
|
|
64
|
+
<th class="py-1 whitespace-nowrap">Total</th>
|
|
65
|
+
</tr></thead>
|
|
66
|
+
<tbody>
|
|
67
|
+
<tr v-for="q in queries.data.value?.top ?? []" :key="q.id" class="border-t border-card-line">
|
|
68
|
+
<td class="py-1 pr-4 font-mono text-xs truncate max-w-md">{{ q.query }}</td>
|
|
69
|
+
<td class="py-1 pr-4">{{ Number(q.calls).toLocaleString('es-CL') }}</td>
|
|
70
|
+
<td class="py-1 pr-4">{{ fmtMs(q.mean_ms) }}</td>
|
|
71
|
+
<td class="py-1">{{ fmtMs(q.total_ms) }}</td>
|
|
72
|
+
</tr>
|
|
73
|
+
</tbody>
|
|
74
|
+
</table>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<!-- Índices sin usar + tablas con dead tuples -->
|
|
78
|
+
<div class="grid gap-6 lg:grid-cols-2">
|
|
79
|
+
<section>
|
|
80
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Índices sin usar</h3>
|
|
81
|
+
<table class="w-full text-sm">
|
|
82
|
+
<thead><tr class="text-left text-muted-foreground"><th class="py-1">Índice</th><th class="py-1">Tabla</th><th class="py-1">Tamaño</th></tr></thead>
|
|
83
|
+
<tbody>
|
|
84
|
+
<tr v-for="ix in indexes.data.value?.unused ?? []" :key="ix.index" class="border-t border-card-line">
|
|
85
|
+
<td class="py-1 font-mono text-xs">{{ ix.index }}</td><td class="py-1">{{ ix.table }}</td><td class="py-1">{{ fmtBytes(ix.bytes) }}</td>
|
|
86
|
+
</tr>
|
|
87
|
+
<tr v-if="!(indexes.data.value?.unused?.length)"><td colspan="3" class="py-2 text-xs text-muted-foreground">Sin índices sin usar 🎉</td></tr>
|
|
88
|
+
</tbody>
|
|
89
|
+
</table>
|
|
90
|
+
</section>
|
|
91
|
+
<section>
|
|
92
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Tablas con más dead tuples</h3>
|
|
93
|
+
<table class="w-full text-sm">
|
|
94
|
+
<thead><tr class="text-left text-muted-foreground"><th class="py-1">Tabla</th><th class="py-1">Muertas</th><th class="py-1">Bloat</th><th class="py-1">Tamaño</th></tr></thead>
|
|
95
|
+
<tbody>
|
|
96
|
+
<tr v-for="t in tables.data.value?.tables ?? []" :key="t.name" class="border-t border-card-line">
|
|
97
|
+
<td class="py-1">{{ t.name }}</td><td class="py-1">{{ Number(t.dead).toLocaleString('es-CL') }}</td>
|
|
98
|
+
<td class="py-1">{{ t.bloat_pct }}%</td><td class="py-1">{{ fmtBytes(t.bytes) }}</td>
|
|
99
|
+
</tr>
|
|
100
|
+
</tbody>
|
|
101
|
+
</table>
|
|
102
|
+
</section>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</template>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Detalle de un caso de error (Sentry-like): header + criticidad, timeline de
|
|
3
|
+
// ocurrencias, stack trace, contexto del request y traces de muestra.
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
id: { type: [String, Number], required: true },
|
|
6
|
+
})
|
|
7
|
+
const emit = defineEmits(['back'])
|
|
8
|
+
const api = useApi()
|
|
9
|
+
|
|
10
|
+
const { data, pending } = await useAsyncData(
|
|
11
|
+
() => `obs-error-${props.id}`,
|
|
12
|
+
() => api.get(`platform/observability/errors/${props.id}`),
|
|
13
|
+
{ watch: [() => props.id] },
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const exceptionEvent = computed(() => (data.value?.events ?? []).find(e => e.type === 'exception'))
|
|
17
|
+
const stackFrames = computed(() => exceptionEvent.value?.payload?.stack ?? exceptionEvent.value?.payload?.frames ?? [])
|
|
18
|
+
|
|
19
|
+
const timelineSeries = computed(() => [{ name: 'Ocurrencias', data: (data.value?.timeline ?? []).map(d => d.count) }])
|
|
20
|
+
const timelineCats = computed(() => (data.value?.timeline ?? []).map(d => d.day))
|
|
21
|
+
|
|
22
|
+
const STATUS = { open: 'Abierto', resolved: 'Resuelto', ignored: 'Ignorado' }
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div class="space-y-5">
|
|
27
|
+
<button type="button" class="text-sm text-primary hover:underline" @click="emit('back')">← Volver a errores</button>
|
|
28
|
+
|
|
29
|
+
<div v-if="pending" class="py-10 text-center text-sm text-muted-foreground">Cargando…</div>
|
|
30
|
+
|
|
31
|
+
<template v-else-if="data">
|
|
32
|
+
<div class="rounded-card border border-card-line bg-card p-4">
|
|
33
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
34
|
+
<span v-if="data.is_critical" class="rounded-full bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300 px-2 py-0.5 text-xs font-semibold">Crítico</span>
|
|
35
|
+
<span class="rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300 px-2 py-0.5 text-xs">{{ STATUS[data.case.status] ?? data.case.status }}</span>
|
|
36
|
+
<span class="text-xs text-muted-foreground">{{ data.case.times_seen }} ocurrencias</span>
|
|
37
|
+
</div>
|
|
38
|
+
<h2 class="mt-2 text-lg font-semibold text-foreground">{{ data.case.title }}</h2>
|
|
39
|
+
<p class="text-sm text-muted-foreground font-mono">{{ data.case.exception_class }}</p>
|
|
40
|
+
<p class="mt-1 text-xs text-muted-foreground">Primera vez: {{ data.case.first_seen }} · Última: {{ data.case.last_seen }}</p>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<section v-if="timelineCats.length">
|
|
44
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Ocurrencias (30 días)</h3>
|
|
45
|
+
<Chart type="bar" :series="timelineSeries" :categories="timelineCats" :height="160" />
|
|
46
|
+
</section>
|
|
47
|
+
|
|
48
|
+
<section v-if="data.trace" class="rounded-card border border-card-line bg-card p-4 text-sm">
|
|
49
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Contexto del request</h3>
|
|
50
|
+
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
|
51
|
+
<div><dt class="text-muted-foreground inline">Ruta:</dt> <dd class="inline text-foreground">{{ data.trace.method }} {{ data.trace.route ?? data.trace.url }}</dd></div>
|
|
52
|
+
<div><dt class="text-muted-foreground inline">Status:</dt> <dd class="inline text-foreground">{{ data.trace.status ?? '—' }}</dd></div>
|
|
53
|
+
<div><dt class="text-muted-foreground inline">Usuario:</dt> <dd class="inline text-foreground">{{ data.trace.user_id ?? '—' }}</dd></div>
|
|
54
|
+
<div><dt class="text-muted-foreground inline">Tenant:</dt> <dd class="inline text-foreground">{{ data.trace.tenant ?? '—' }}</dd></div>
|
|
55
|
+
<div><dt class="text-muted-foreground inline">IP:</dt> <dd class="inline text-foreground">{{ data.trace.ip ?? '—' }}</dd></div>
|
|
56
|
+
<div><dt class="text-muted-foreground inline">Duración:</dt> <dd class="inline text-foreground">{{ data.trace.duration_ms != null ? data.trace.duration_ms + ' ms' : '—' }}</dd></div>
|
|
57
|
+
</dl>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
60
|
+
<section v-if="stackFrames.length">
|
|
61
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Stack trace</h3>
|
|
62
|
+
<ol class="rounded-card border border-card-line bg-card divide-y divide-card-line text-xs font-mono">
|
|
63
|
+
<li v-for="(f, i) in stackFrames" :key="i" class="px-3 py-1.5">
|
|
64
|
+
<span class="text-foreground">{{ f.function ?? f.call ?? '' }}</span>
|
|
65
|
+
<span class="text-muted-foreground"> — {{ f.file ?? '' }}<span v-if="f.line">:{{ f.line }}</span></span>
|
|
66
|
+
</li>
|
|
67
|
+
</ol>
|
|
68
|
+
</section>
|
|
69
|
+
<section v-else-if="exceptionEvent">
|
|
70
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Excepción</h3>
|
|
71
|
+
<pre class="rounded-card border border-card-line bg-card p-3 text-xs whitespace-pre-wrap">{{ exceptionEvent.payload?.message ?? data.case.title }}</pre>
|
|
72
|
+
</section>
|
|
73
|
+
|
|
74
|
+
<section v-if="data.samples?.length">
|
|
75
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Ocurrencias de muestra</h3>
|
|
76
|
+
<ul class="text-xs text-muted-foreground space-y-1">
|
|
77
|
+
<li v-for="s in data.samples" :key="s.trace_id">{{ s.occurred_at }} · {{ s.method }} {{ s.route ?? s.url }} · {{ s.status ?? '—' }}</li>
|
|
78
|
+
</ul>
|
|
79
|
+
</section>
|
|
80
|
+
</template>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
// Lista de errores (casos) con filtros + criticidad + entrar al detalle.
|
|
3
|
+
// Realtime: el Panel cambia `touchedAt` al recibir case.touched → recarga la tabla
|
|
4
|
+
// (bump de :key). La suscripción vive en el Panel (useRealtime es singleton por canal).
|
|
5
|
+
const props = defineProps({ touchedAt: { type: Number, default: 0 } })
|
|
2
6
|
const api = useApi()
|
|
3
7
|
const toast = useToast()
|
|
8
|
+
|
|
9
|
+
const selectedId = ref(null)
|
|
4
10
|
const reloadKey = ref(0)
|
|
5
11
|
|
|
6
12
|
const columns = [
|
|
7
13
|
{ key: 'last_seen', label: 'Última vez', sortable: true },
|
|
8
14
|
{ key: 'title', label: 'Error' },
|
|
9
15
|
{ key: 'exception_class', label: 'Clase' },
|
|
10
|
-
{ key: 'times_seen', label: 'Veces' },
|
|
11
|
-
{ key: 'status', label: 'Estado'
|
|
16
|
+
{ key: 'times_seen', label: 'Veces', sortable: true },
|
|
17
|
+
{ key: 'status', label: 'Estado', filterable: true, filterType: 'select',
|
|
18
|
+
filterOptions: [
|
|
19
|
+
{ value: 'open', label: 'Abierto' },
|
|
20
|
+
{ value: 'resolved', label: 'Resuelto' },
|
|
21
|
+
{ value: 'ignored', label: 'Ignorado' },
|
|
22
|
+
] },
|
|
12
23
|
{ key: 'actions', label: '' },
|
|
13
24
|
]
|
|
14
25
|
|
|
@@ -23,22 +34,36 @@ async function setStatus(row, status) {
|
|
|
23
34
|
toast.error(e?.data?.message ?? 'No se pudo cambiar el estado.')
|
|
24
35
|
}
|
|
25
36
|
}
|
|
37
|
+
|
|
38
|
+
watch(() => props.touchedAt, () => { reloadKey.value++ })
|
|
26
39
|
</script>
|
|
27
40
|
|
|
28
41
|
<template>
|
|
29
|
-
<
|
|
30
|
-
:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
<div>
|
|
43
|
+
<Observability.ErrorDetail v-if="selectedId" :id="selectedId" @back="selectedId = null" />
|
|
44
|
+
|
|
45
|
+
<Table.Standard
|
|
46
|
+
v-else
|
|
47
|
+
:key="reloadKey"
|
|
48
|
+
name="observability-errors"
|
|
49
|
+
endpoint="platform/observability/errors"
|
|
50
|
+
:columns="columns"
|
|
51
|
+
search-placeholder="Buscar error…"
|
|
52
|
+
@row-click="(row) => selectedId = row.id"
|
|
53
|
+
>
|
|
54
|
+
<template #title="{ row }">
|
|
55
|
+
<div class="flex items-center gap-2">
|
|
56
|
+
<span v-if="row.is_critical" class="inline-block size-2 rounded-full bg-red-500" title="Crítico" />
|
|
57
|
+
<span class="text-foreground">{{ row.title }}</span>
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
<template #status="{ value }">{{ statusLabels[value] ?? value }}</template>
|
|
61
|
+
<template #actions="{ row }">
|
|
62
|
+
<div class="flex gap-2">
|
|
63
|
+
<button class="text-xs text-muted-foreground hover:text-foreground" @click.stop="setStatus(row, 'resolved')">Resolver</button>
|
|
64
|
+
<button class="text-xs text-muted-foreground hover:text-foreground" @click.stop="setStatus(row, 'ignored')">Ignorar</button>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
</Table.Standard>
|
|
68
|
+
</div>
|
|
44
69
|
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const props = defineProps({
|
|
3
|
+
label: { type: String, required: true },
|
|
4
|
+
percent: { type: Number, default: null }, // 0..100 o null
|
|
5
|
+
})
|
|
6
|
+
const series = computed(() => [props.percent == null ? 0 : Math.max(0, Math.min(100, props.percent))])
|
|
7
|
+
const options = computed(() => ({
|
|
8
|
+
labels: [props.label],
|
|
9
|
+
plotOptions: { radialBar: { dataLabels: { value: { formatter: (v) => `${Math.round(v)}%` } } } },
|
|
10
|
+
}))
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div class="rounded-card border border-card-line bg-card p-2">
|
|
15
|
+
<Chart v-if="percent != null" type="radialBar" :series="series" :options="options" :height="180" />
|
|
16
|
+
<div v-else class="flex h-[180px] flex-col items-center justify-center text-muted-foreground">
|
|
17
|
+
<span class="text-sm">{{ label }}</span>
|
|
18
|
+
<span class="text-xs">sin datos</span>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
defineProps({
|
|
3
|
+
label: { type: String, required: true },
|
|
4
|
+
value: { type: [String, Number], default: '—' },
|
|
5
|
+
sublabel: { type: String, default: '' },
|
|
6
|
+
tone: { type: String, default: 'neutral' }, // neutral | good | warn | bad
|
|
7
|
+
})
|
|
8
|
+
const TONE = {
|
|
9
|
+
neutral: 'text-foreground',
|
|
10
|
+
good: 'text-emerald-600 dark:text-emerald-400',
|
|
11
|
+
warn: 'text-amber-600 dark:text-amber-400',
|
|
12
|
+
bad: 'text-red-600 dark:text-red-400',
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<div class="rounded-card border border-card-line bg-card p-4">
|
|
18
|
+
<p class="text-xs text-muted-foreground">{{ label }}</p>
|
|
19
|
+
<p class="mt-1 text-2xl font-bold" :class="TONE[tone] ?? TONE.neutral">{{ value }}</p>
|
|
20
|
+
<p v-if="sublabel" class="mt-0.5 text-xs text-muted-foreground">{{ sublabel }}</p>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Overview pro: KPIs de errores (con criticidad), BD, slow queries y colas.
|
|
3
|
+
// Refresca cada ~15s y en vivo cuando el Panel cambia `touchedAt` (case.touched).
|
|
4
|
+
// La suscripción realtime vive en el Panel (useRealtime es singleton por canal).
|
|
5
|
+
const props = defineProps({ touchedAt: { type: Number, default: 0 } })
|
|
6
|
+
const api = useApi()
|
|
7
|
+
|
|
8
|
+
const { data, refresh } = await useAsyncData('obs-overview', () => api.get('platform/observability/overview'))
|
|
9
|
+
const timer = ref(null)
|
|
10
|
+
|
|
11
|
+
onMounted(() => { timer.value = setInterval(refresh, 15_000) })
|
|
12
|
+
onBeforeUnmount(() => { if (timer.value) clearInterval(timer.value) })
|
|
13
|
+
|
|
14
|
+
watch(() => props.touchedAt, () => refresh())
|
|
15
|
+
|
|
16
|
+
function fmtBytes(b) {
|
|
17
|
+
if (b == null) return '—'
|
|
18
|
+
const u = ['B', 'KB', 'MB', 'GB', 'TB']; let n = Number(b), i = 0
|
|
19
|
+
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ }
|
|
20
|
+
return `${n.toFixed(1)} ${u[i]}`
|
|
21
|
+
}
|
|
22
|
+
function fmtMs(ms) { return ms == null ? '—' : `${Number(ms).toLocaleString('es-CL')} ms` }
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div v-if="data" class="space-y-6">
|
|
27
|
+
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
28
|
+
<Observability.MetricCard label="Errores abiertos" :value="data.errors.open"
|
|
29
|
+
:tone="data.errors.open > 0 ? 'warn' : 'good'"
|
|
30
|
+
:sublabel="`${data.errors.critical} críticos`" />
|
|
31
|
+
<Observability.MetricCard label="Críticos" :value="data.errors.critical"
|
|
32
|
+
:tone="data.errors.critical > 0 ? 'bad' : 'good'" />
|
|
33
|
+
<Observability.MetricCard label="Cache hit"
|
|
34
|
+
:value="data.database.cache_hit_ratio != null ? data.database.cache_hit_ratio + '%' : '—'"
|
|
35
|
+
:tone="(data.database.cache_hit_ratio ?? 100) >= 95 ? 'good' : 'warn'"
|
|
36
|
+
:sublabel="`BD: ${fmtBytes(data.database.database_bytes)}`" />
|
|
37
|
+
<Observability.MetricCard label="Colas"
|
|
38
|
+
:value="data.queue.pending ?? '—'"
|
|
39
|
+
:tone="(data.queue.failed ?? 0) > 0 ? 'bad' : 'neutral'"
|
|
40
|
+
:sublabel="`${data.queue.failed ?? 0} fallidos`" />
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="flex flex-wrap gap-2 text-xs">
|
|
44
|
+
<span class="rounded-full border border-card-line bg-card px-2.5 py-1 text-muted-foreground">Resueltos: <span class="font-semibold text-foreground">{{ data.errors.resolved }}</span></span>
|
|
45
|
+
<span class="rounded-full border border-card-line bg-card px-2.5 py-1 text-muted-foreground">Ignorados: <span class="font-semibold text-foreground">{{ data.errors.ignored }}</span></span>
|
|
46
|
+
<span class="rounded-full border border-card-line bg-card px-2.5 py-1 text-muted-foreground">Errores 24h (log): <span class="font-semibold text-foreground">{{ data.logs?.errors_24h ?? 0 }}</span></span>
|
|
47
|
+
<span class="rounded-full border border-card-line bg-card px-2.5 py-1 text-muted-foreground">Conexiones: <span class="font-semibold text-foreground">{{ data.database.connections.total ?? '—' }}/{{ data.database.connections.max ?? '—' }}</span></span>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<section>
|
|
51
|
+
<h3 class="mb-2 text-sm font-semibold text-foreground">Top consultas lentas</h3>
|
|
52
|
+
<table class="w-full text-sm">
|
|
53
|
+
<thead><tr class="text-left text-muted-foreground">
|
|
54
|
+
<th class="py-1 pr-4">Query</th>
|
|
55
|
+
<th class="py-1 pr-4 whitespace-nowrap">Llamadas</th>
|
|
56
|
+
<th class="py-1 pr-4 whitespace-nowrap">Media</th>
|
|
57
|
+
<th class="py-1 whitespace-nowrap">Total</th>
|
|
58
|
+
</tr></thead>
|
|
59
|
+
<tbody>
|
|
60
|
+
<tr v-for="q in data.slow_queries" :key="q.id" class="border-t border-card-line">
|
|
61
|
+
<td class="py-1 pr-4 font-mono text-xs truncate max-w-md">{{ q.query }}</td>
|
|
62
|
+
<td class="py-1 pr-4">{{ Number(q.calls).toLocaleString('es-CL') }}</td>
|
|
63
|
+
<td class="py-1 pr-4">{{ fmtMs(q.mean_ms) }}</td>
|
|
64
|
+
<td class="py-1">{{ fmtMs(q.total_ms) }}</td>
|
|
65
|
+
</tr>
|
|
66
|
+
<tr v-if="!data.slow_queries?.length"><td colspan="4" class="py-2 text-xs text-muted-foreground">Sin datos de consultas.</td></tr>
|
|
67
|
+
</tbody>
|
|
68
|
+
</table>
|
|
69
|
+
</section>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
// Panel drop-in de observabilidad. Overview es la landing; luego BD, Errores, Logs, Archivos.
|
|
3
|
+
// Realtime: UNA sola suscripción al canal 'observability' acá (useRealtime es singleton
|
|
4
|
+
// por canal; suscribir en cada tab se pisaría el handler). Al recibir case.touched se
|
|
5
|
+
// bumpea `touchedAt` y los tabs (Overview/Errores) reaccionan por prop.
|
|
6
|
+
const rt = useRealtime()
|
|
7
|
+
const touchedAt = ref(0)
|
|
8
|
+
|
|
9
|
+
const TABS = [
|
|
10
|
+
{ key: 'overview', label: 'Resumen' },
|
|
11
|
+
{ key: 'database', label: 'Base de datos' },
|
|
12
|
+
{ key: 'errors', label: 'Errores' },
|
|
13
|
+
{ key: 'logs', label: 'Logs' },
|
|
14
|
+
{ key: 'files', label: 'Archivos' },
|
|
15
|
+
]
|
|
16
|
+
const active = ref('overview')
|
|
17
|
+
|
|
18
|
+
onMounted(async () => {
|
|
19
|
+
try { await rt.connect(); rt.subscribe('observability', { 'case.touched': () => { touchedAt.value = Date.now() } }) } catch {}
|
|
20
|
+
})
|
|
21
|
+
onBeforeUnmount(() => rt.unsubscribe('observability'))
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div class="space-y-5">
|
|
26
|
+
<nav class="flex gap-1 border-b border-card-line overflow-x-auto">
|
|
27
|
+
<button v-for="t in TABS" :key="t.key" type="button" @click="active = t.key"
|
|
28
|
+
class="px-3 py-2 text-sm font-medium transition-colors -mb-px border-b-2 whitespace-nowrap"
|
|
29
|
+
:class="active === t.key ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'">
|
|
30
|
+
{{ t.label }}
|
|
31
|
+
</button>
|
|
32
|
+
</nav>
|
|
33
|
+
|
|
34
|
+
<div v-show="active === 'overview'"><Observability.Overview :touched-at="touchedAt" /></div>
|
|
35
|
+
<div v-show="active === 'database'"><Observability.Dashboard /></div>
|
|
36
|
+
<div v-show="active === 'errors'"><Observability.Errors :touched-at="touchedAt" /></div>
|
|
37
|
+
<div v-show="active === 'logs'"><Observability.Logs channel="operational" /></div>
|
|
38
|
+
<div v-show="active === 'files'">
|
|
39
|
+
<p class="rounded-card border border-dashed border-card-line p-8 text-center text-sm text-muted-foreground">
|
|
40
|
+
Visor de archivos de log — próximamente (Fase 4).
|
|
41
|
+
</p>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
package/package.json
CHANGED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
const api = useApi()
|
|
3
|
-
|
|
4
|
-
const { data, pending, error } = await useAsyncData(
|
|
5
|
-
'observability-database',
|
|
6
|
-
() => api.get('platform/observability/database'),
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
function fmtBytes(bytes) {
|
|
10
|
-
if (bytes == null) return '—'
|
|
11
|
-
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
12
|
-
let n = Number(bytes), i = 0
|
|
13
|
-
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
|
14
|
-
return `${n.toFixed(1)} ${units[i]}`
|
|
15
|
-
}
|
|
16
|
-
</script>
|
|
17
|
-
|
|
18
|
-
<template>
|
|
19
|
-
<div>
|
|
20
|
-
<div v-if="pending" class="text-sm text-muted-foreground">Cargando…</div>
|
|
21
|
-
<div v-else-if="error" class="text-sm text-red-500">No se pudo cargar la capacidad de la BD.</div>
|
|
22
|
-
<div v-else class="space-y-6">
|
|
23
|
-
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
|
24
|
-
<div class="rounded-card border border-border p-4">
|
|
25
|
-
<p class="text-xs text-muted-foreground">Tamaño de la BD</p>
|
|
26
|
-
<p class="mt-1 text-lg font-bold text-foreground">{{ fmtBytes(data.capacity?.database_bytes) }}</p>
|
|
27
|
-
</div>
|
|
28
|
-
<div class="rounded-card border border-border p-4">
|
|
29
|
-
<p class="text-xs text-muted-foreground">Conexiones</p>
|
|
30
|
-
<p class="mt-1 text-lg font-bold text-foreground">
|
|
31
|
-
{{ data.capacity?.connections?.used ?? '—' }} / {{ data.capacity?.connections?.max ?? '—' }}
|
|
32
|
-
</p>
|
|
33
|
-
</div>
|
|
34
|
-
<div class="rounded-card border border-border p-4">
|
|
35
|
-
<p class="text-xs text-muted-foreground">Disco usado</p>
|
|
36
|
-
<p class="mt-1 text-lg font-bold text-foreground">{{ data.disk?.percent != null ? data.disk.percent + '%' : '—' }}</p>
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
|
|
40
|
-
<div>
|
|
41
|
-
<h2 class="mb-2 text-sm font-semibold text-foreground">Tablas más grandes</h2>
|
|
42
|
-
<table class="w-full text-sm">
|
|
43
|
-
<thead>
|
|
44
|
-
<tr class="text-left text-muted-foreground">
|
|
45
|
-
<th class="py-1">Tabla</th><th class="py-1">Tamaño</th><th class="py-1">Filas</th>
|
|
46
|
-
</tr>
|
|
47
|
-
</thead>
|
|
48
|
-
<tbody>
|
|
49
|
-
<tr v-for="t in data.capacity?.tables ?? []" :key="t.name" class="border-t border-border">
|
|
50
|
-
<td class="py-1">{{ t.name }}</td>
|
|
51
|
-
<td class="py-1">{{ fmtBytes(t.bytes) }}</td>
|
|
52
|
-
<td class="py-1">{{ Number(t.rows).toLocaleString('es-CL') }}</td>
|
|
53
|
-
</tr>
|
|
54
|
-
</tbody>
|
|
55
|
-
</table>
|
|
56
|
-
</div>
|
|
57
|
-
</div>
|
|
58
|
-
</div>
|
|
59
|
-
</template>
|