first commit

This commit is contained in:
alexandrump
2026-02-09 01:02:53 +01:00
commit 82f3464565
90 changed files with 4788 additions and 0 deletions

549
apps/web/pages/admin.vue Normal file
View File

@@ -0,0 +1,549 @@
<template>
<div class="page">
<Header />
<main class="container">
<header class="page-header">
<div>
<h1>Admin Control de ingesta</h1>
<p>Gestiona el scheduler y encola la ingesta manualmente.</p>
</div>
</header>
<section class="panel">
<div class="grid">
<div>
<label>Email (admin)</label>
<input v-model="email" placeholder="admin@example.com" />
<label>Password</label>
<input type="password" v-model="password" placeholder="password" />
<div class="row">
<button class="btn" @click="login">Login</button>
<button class="btn ghost" @click="logout">Logout</button>
</div>
</div>
<div>
<label>API Key (fallback)</label>
<input v-model="key" placeholder="Introduce API key" />
</div>
</div>
</section>
<section class="panel">
<div class="row">
<button class="btn" @click="pause">Pause scheduler</button>
<button class="btn ghost" @click="resume">Resume scheduler</button>
<button class="btn" @click="queue">Enqueue ingest</button>
<button class="btn ghost" @click="run">Run now (sync)</button>
</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Monitorización</h2>
<button class="btn ghost" @click="loadStatus">Refresh</button>
</header>
<div v-if="status" class="status-grid">
<div class="status-card">
<strong>Catálogo</strong>
<span>Items: {{ status.counts.catalogItems }}</span>
<span>Versiones: {{ status.counts.catalogVersions }}</span>
</div>
<div class="status-card">
<strong>Alertas</strong>
<span>Perfiles: {{ status.counts.alertProfiles }}</span>
<span>Entregas: {{ status.counts.alertDeliveries }}</span>
</div>
<div class="status-card">
<strong>Usuarios</strong>
<span>Total: {{ status.counts.users }}</span>
</div>
<div class="status-card">
<strong>Última ingesta</strong>
<span>{{ status.lastIngest?.status || '—' }}</span>
<span>{{ formatDate(status.lastIngest?.startedAt) }}</span>
</div>
<div class="status-card">
<strong>Últimas alertas</strong>
<span>{{ status.lastAlerts?.status || '—' }}</span>
<span>{{ formatDate(status.lastAlerts?.startedAt) }}</span>
</div>
</div>
<div v-else class="muted">Sin datos de monitorización.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Backups</h2>
<div class="row">
<button class="btn" @click="runBackup">Run backup</button>
<button class="btn ghost" @click="loadBackups">Refresh</button>
</div>
</header>
<ul v-if="backups.length" class="backup-list">
<li v-for="file in backups" :key="file">{{ file }}</li>
</ul>
<div v-else class="muted">No hay backups aún.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Ingest runs</h2>
<button class="btn ghost" @click="loadRuns">Refresh</button>
</header>
<div v-if="runs.length" class="runs">
<div v-for="run in runs" :key="run.id" class="run-row">
<div>
<strong>{{ run.status }}</strong>
<div class="muted">Inicio: {{ formatDate(run.startedAt) }}</div>
<div class="muted" v-if="run.finishedAt">Fin: {{ formatDate(run.finishedAt) }}</div>
</div>
<div class="metrics">
<span>Importados: {{ run.imported }}</span>
<span>Actualizados: {{ run.updated }}</span>
<span>Errores: {{ run.errorCount }}</span>
<span v-if="run.durationMs">Duración: {{ Math.round(run.durationMs / 1000) }}s</span>
</div>
</div>
</div>
<div v-else class="muted">Sin ejecuciones recientes.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Planes</h2>
<button class="btn ghost" @click="loadPlans">Refresh</button>
</header>
<div v-if="plans.length" class="plans">
<div v-for="plan in plans" :key="plan.id" class="plan-row">
<div>
<strong>{{ plan.name }}</strong>
<div class="muted">{{ plan.code }} · {{ plan.priceMonthly }} {{ plan.currency }}/mes</div>
</div>
<div class="muted">{{ plan.description }}</div>
</div>
</div>
<div v-else class="muted">Sin planes.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Usuarios</h2>
<button class="btn ghost" @click="loadUsers">Refresh</button>
</header>
<div v-if="users.length" class="users">
<div v-for="user in users" :key="user.id" class="user-row">
<div>
<strong>{{ user.email }}</strong>
<div class="muted">{{ user.role }} · {{ formatDate(user.createdAt) }}</div>
</div>
<div class="user-actions">
<select v-model="user.planId" @change="updateUserPlan(user)">
<option value="">Sin plan</option>
<option v-for="plan in plans" :key="plan.id" :value="plan.id">
{{ plan.name }}
</option>
</select>
<select v-model="user.role" @change="updateUserRole(user)">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
</div>
</div>
<div v-else class="muted">Sin usuarios.</div>
</section>
<section class="panel">
<header class="section-head">
<h2>Alert runs</h2>
<button class="btn ghost" @click="loadAlertRuns">Refresh</button>
</header>
<div v-if="alertRuns.length" class="runs">
<div v-for="run in alertRuns" :key="run.id" class="run-row">
<div>
<strong>{{ run.status }}</strong>
<div class="muted">Inicio: {{ formatDate(run.startedAt) }}</div>
<div class="muted" v-if="run.finishedAt">Fin: {{ formatDate(run.finishedAt) }}</div>
</div>
<div class="metrics">
<span>Perfiles: {{ run.profiles }}</span>
<span>Enviadas: {{ run.sent }}</span>
<span>Fallidas: {{ run.failed }}</span>
<span v-if="run.durationMs">Duración: {{ Math.round(run.durationMs / 1000) }}s</span>
</div>
</div>
</div>
<div v-else class="muted">Sin ejecuciones recientes.</div>
</section>
<section class="panel" v-if="msg">
<pre>{{ msg }}</pre>
</section>
</main>
</div>
</template>
<script setup>
import Header from '~/components/Header.vue'
import { ref } from 'vue'
const config = useRuntimeConfig()
const key = ref(localStorage.getItem('admin_key') || '')
const token = ref(localStorage.getItem('admin_token') || '')
const email = ref('')
const password = ref('')
const msg = ref('')
const runs = ref([])
const alertRuns = ref([])
const plans = ref([])
const users = ref([])
const status = ref(null)
const backups = ref([])
function saveKey() {
localStorage.setItem('admin_key', key.value)
}
function saveToken(t) {
token.value = t
if (t) localStorage.setItem('admin_token', t)
else localStorage.removeItem('admin_token')
}
async function login() {
try {
const res = await $fetch(`${config.public.apiBase}/auth/login`, { method: 'POST', body: { email: email.value, password: password.value } })
saveToken(res.access_token)
msg.value = 'Logged in'
await loadStatus()
await loadBackups()
await loadRuns()
await loadAlertRuns()
await loadPlans()
await loadUsers()
} catch (e) { msg.value = 'Login failed: ' + String(e) }
}
function logout() {
saveToken('')
msg.value = 'Logged out'
}
async function pause() {
saveKey()
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
const res = await $fetch(`${config.public.apiBase}/admin/ingest/pause`, { method: 'POST', headers })
msg.value = JSON.stringify(res, null, 2)
await loadRuns()
} catch (e) { msg.value = String(e) }
}
async function resume() {
saveKey()
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
const res = await $fetch(`${config.public.apiBase}/admin/ingest/resume`, { method: 'POST', headers })
msg.value = JSON.stringify(res, null, 2)
await loadRuns()
} catch (e) { msg.value = String(e) }
}
async function queue() {
saveKey()
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
const res = await $fetch(`${config.public.apiBase}/ingest/queue`, { method: 'POST', headers })
msg.value = JSON.stringify(res, null, 2)
await loadRuns()
} catch (e) { msg.value = String(e) }
}
async function run() {
try {
const res = await $fetch(`${config.public.apiBase}/ingest/run`)
msg.value = JSON.stringify(res, null, 2)
await loadRuns()
} catch (e) { msg.value = String(e) }
}
async function loadStatus() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/monitor/status`, { headers })
status.value = res
} catch (e) {
msg.value = String(e)
}
}
async function loadBackups() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/backup/list`, { headers })
backups.value = res || []
} catch (e) {
msg.value = String(e)
}
}
async function runBackup() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/backup/run`, { method: 'POST', headers })
msg.value = JSON.stringify(res, null, 2)
await loadBackups()
} catch (e) {
msg.value = String(e)
}
}
async function loadRuns() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/ingest/runs`, { headers })
runs.value = res || []
} catch (e) {
msg.value = String(e)
}
}
async function loadPlans() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/plans`, { headers })
plans.value = res || []
} catch (e) {
msg.value = String(e)
}
}
async function loadUsers() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/users`, { headers })
users.value = res || []
} catch (e) {
msg.value = String(e)
}
}
async function updateUserPlan(user) {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
await $fetch(`${config.public.apiBase}/admin/users/${user.id}/plan`, {
method: 'POST',
headers,
body: { planId: user.planId }
})
messageToast('Plan actualizado')
} catch (e) {
msg.value = String(e)
}
}
async function updateUserRole(user) {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
await $fetch(`${config.public.apiBase}/admin/users/${user.id}/role`, {
method: 'POST',
headers,
body: { role: user.role }
})
messageToast('Rol actualizado')
} catch (e) {
msg.value = String(e)
}
}
async function loadAlertRuns() {
try {
const headers = {}
if (token.value) headers['authorization'] = `Bearer ${token.value}`
else if (key.value) headers['x-api-key'] = key.value
else return
const res = await $fetch(`${config.public.apiBase}/admin/alerts/runs`, { headers })
alertRuns.value = res || []
} catch (e) {
msg.value = String(e)
}
}
function formatDate(value) {
if (!value) return '—'
return new Date(value).toLocaleString('es-ES')
}
function messageToast(text) {
msg.value = text
}
onMounted(() => {
loadStatus()
loadBackups()
loadRuns()
loadAlertRuns()
loadPlans()
loadUsers()
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f8fafc;
font-family: 'Space Grotesk', 'Segoe UI', sans-serif;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 1.5rem 4rem;
}
.page-header {
margin-bottom: 2rem;
}
.panel {
background: #fff;
border-radius: 20px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.runs {
display: grid;
gap: 1rem;
}
.run-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem;
border-radius: 14px;
background: #f8fafc;
}
.metrics {
display: grid;
gap: 0.3rem;
text-align: right;
font-size: 0.85rem;
}
.muted {
color: #64748b;
font-size: 0.85rem;
}
.plans,
.users {
display: grid;
gap: 1rem;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.status-card {
display: grid;
gap: 0.4rem;
padding: 0.8rem;
border-radius: 14px;
background: #f8fafc;
}
.backup-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.4rem;
}
.plan-row,
.user-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.8rem;
border-radius: 14px;
background: #f8fafc;
align-items: center;
}
.user-actions {
display: flex;
gap: 0.6rem;
align-items: center;
}
select {
padding: 0.4rem 0.6rem;
border-radius: 8px;
border: 1px solid #cbd5f5;
background: #fff;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.8rem;
margin-top: 0.8rem;
}
label {
font-weight: 600;
margin-top: 0.6rem;
display: block;
}
input {
padding: 0.5rem;
border-radius: 8px;
border: 1px solid #cbd5f5;
width: 100%;
}
.btn {
padding: 0.6rem 1.4rem;
border-radius: 999px;
border: none;
background: #0f172a;
color: #fff;
font-weight: 600;
}
.btn.ghost {
background: transparent;
border: 1px solid #0f172a;
color: #0f172a;
}
</style>