/* global React */
// =======================================================================
// BlazeConnector Admin v3 — Administración pages
// Tenants (+ detail) · API Keys · Templates · Auditoría
// =======================================================================
const { useState: _as, useEffect: _ae, useMemo: _am, useRef: _ar } = React;
/* =========================================================================
TENANTS — list
========================================================================= */
function PageTenants({ onOpenTenant, role }) {
const bc = window.useBC();
const TS = bc.tenants;
const live = bc.mode === 'live';
const [search, setSearch] = _as('');
const [planF, setPlanF] = _as('all');
const [statusF, setStatusF] = _as('all');
const [creating, setCreating] = _as(false);
const rows = _am(() => {
const ql = search.toLowerCase();
return TS.filter(t => {
if (planF !== 'all' && t.plan !== planF) return false;
if (statusF !== 'all' && t.status !== statusF) return false;
if (ql && !(t.name + ' ' + t.id + ' ' + t.uuid + ' ' + (t.region || '')).toLowerCase().includes(ql)) return false;
return true;
});
}, [search, planF, statusF, TS]);
const loading = bc.tenantsLoading && TS.length === 0;
const failed = bc.status === 'error' && TS.length === 0;
return (
Tenants {live && live }
{rows.length} de {TS.length} ISPs · {TS.filter(t => t.status === 'active').length} activos · {TS.filter(t => t.status === 'suspended').length} suspendidos
Export CSV
{window.hasPerm(role, 'tenant:edit') && setCreating(true)}> Crear tenant }
a + (t.msgs24h || 0), 0)} unit="" color="orange" delta={live ? null : 4.2} series={live ? null : window.DASHBOARD_SERIES.msgsPerMin.slice(0, 30)} foot={live ? 'no expuesto por admin API' : null} />
a + (t.subs || 0), 0)} color="blue" foot={live ? 'no expuesto por admin API' : 'end-users servidos'} />
t.status === 'active').length} color="green" foot={'de ' + TS.length + ' tenants'} />
{['all','Enterprise','Pro','Starter'].map(p => (
setPlanF(p)}>{p === 'all' ? 'Todos los planes' : p}
))}
{['all','active','suspended','inactive'].map(s => (
setStatusF(s)}>{s === 'all' ? 'Todos' : s}
))}
{loading ?
: failed ?
window.BC.loadTenants()}>Reintentar} />
: onOpenTenant(r.id)} columns={[
{ label: 'Tenant', render: (t) => (
) },
{ label: 'Plan', width: 110, render: (t) => {t.plan} },
{ label: 'Estado', width: 100, render: (t) => },
{ label: 'Canales', width: 120, render: (t) => t.channels.length ? {t.channels.map(c => )}
: — },
{ label: 'Billing', width: 110, render: (t) => {t.billing || '—'} },
{ label: 'Módulos', width: 70, numeric: true, render: (t) => {t.modules.length} },
{ label: 'Pagos', width: 90, render: (t) => {t.pagos.length ? t.pagos.join(', ') : '—'} },
{ label: 'Actualizado', width: 130, render: (t) => hace {window.timeAgo(t.lastActive)} },
{ label: '', width: 40, render: () => e.stopPropagation()}> }
]} rows={rows} />}
{creating &&
setCreating(false)} onCreated={(uuid) => { setCreating(false); if (uuid) onOpenTenant(uuid); }} />}
);
}
/* Crear tenant — POST /admin/tenants real en modo live */
function CreateTenantModal({ live, onClose, onCreated }) {
const [name, setName] = _as('');
const [plan, setPlan] = _as('Pro');
const [busy, setBusy] = _as(false);
const [err, setErr] = _as(null);
const PLAN_API = { Starter: 'free', Pro: 'pro', Enterprise: 'enterprise' };
async function submit() {
if (!name.trim()) { setErr('El nombre es obligatorio'); return; }
if (!live) { onCreated(null); return; }
setBusy(true); setErr(null);
try {
const t = await window.BC.createTenant({ name: name.trim(), plan: PLAN_API[plan] });
onCreated(t && t.uuid);
} catch (e) { setErr(e.message); setBusy(false); }
}
return (
Cancelar
{busy ? 'Creando…' : 'Crear tenant'}
>
}>
Nombre comercial
setName(e.target.value)} autoFocus placeholder="ej. NorteFibra" style={{ background: 'var(--app-input-bg)', border: '1px solid var(--app-border)', borderRadius: 6, padding: '8px 10px', color: 'var(--app-fg1)', fontFamily: 'inherit', fontSize: 13 }} />
Plan
{['Starter', 'Pro', 'Enterprise'].map(p => setPlan(p)}>{p} )}
{err &&
{err}
}
Los módulos y proveedores se configuran luego desde el detalle del tenant.
);
}
/* =========================================================================
TENANT DETAIL
========================================================================= */
function PageTenantDetail({ tenantId, onBack, role }) {
const bc = window.useBC();
const live = bc.mode === 'live';
const tenant = bc.tenants.find(t => t.id === tenantId);
const [tab, setTab] = _as('overview');
if (!tenant) return
;
const liveKeys = (bc.keysByTenant[tenant.uuid] || {}).data;
const liveTpls = (bc.templatesByTenant[tenant.uuid] || {}).data;
return (
{tenant.name}
{tenant.id} · {tenant.uuid} · {tenant.region}
Ver mensajes
{window.hasPerm(role, 'tenant:edit') && (tenant.status === 'active'
? Suspender
: Reactivar )}
{window.hasPerm(role, 'tenant:edit') && Editar }
{[
{ id: 'overview', label: 'Overview' },
{ id: 'providers', label: 'Providers', count: tenant.pagos.length + (tenant.billing ? 1 : 0) },
{ id: 'modules', label: 'Módulos', count: tenant.modules.length },
{ id: 'apikeys', label: 'API Keys', count: live ? (liveKeys ? liveKeys.length : null) : window.API_KEYS.filter(k => k.tenant === tenant.id).length },
{ id: 'templates', label: 'Templates', count: live ? (liveTpls ? liveTpls.length : null) : window.TEMPLATES.length },
{ id: 'audit', label: 'Audit', count: live ? null : window.AUDIT_EVENTS.filter(a => a.tenant === tenant.id).length }
].map(t => (
setTab(t.id)}>
{t.label}{t.count != null && {t.count} }
))}
{tab === 'overview' &&
}
{tab === 'providers' &&
}
{tab === 'modules' &&
}
{tab === 'apikeys' &&
}
{tab === 'templates' &&
}
{tab === 'audit' &&
}
);
}
function TenantOverview({ tenant }) {
const series = window.DASHBOARD_SERIES.msgsPerMin.map((v) => Math.round(v * (tenant.msgs24h / 60000)));
return (
<>
2 ? 'danger' : tenant.errorRate > 1 ? 'warn' : 'green'} delta={tenant.errorRate > 2 ? 240 : -8} deltaTone={tenant.errorRate > 2 ? 'down' : 'up'} foot="últimos 60m" />
Tenant ID {tenant.id}
UUID {tenant.uuid}
Slug {tenant.slug}.blaze.do
Plan {tenant.plan}
Región {tenant.region}
Estado
Onboarded 12 ago 2024
Última actividad hace {window.timeAgo(tenant.lastActive)}
{window.fmtDateTime(a.ts)} },
{ label: 'Actor', width: 200, render: (a) => {a.actor.type === 'user' ? : }{a.actor.name} },
{ label: 'Acción', width: 200, render: (a) => {a.action} },
{ label: 'Target', render: (a) => {a.target} }
]} rows={window.AUDIT_EVENTS.filter(a => a.tenant === tenant.id || !a.tenant).slice(0, 6)} />
>
);
}
function TenantProviders({ tenant }) {
const billing = window.PROVIDERS_ISP.find(p => p.id === tenant.billing);
const pagos = window.PROVIDERS_PAY.filter(p => tenant.pagos.includes(p.id));
return (
{billing ? (
{billing.name[0]}
{billing.name}
{billing.version} · {billing.apiBase}
Latencia avg {billing.latency}ms
Tenants compartidos {billing.tenants}
Auth OAuth2 client_credentials
Webhook URL {'https://api.blaze.do/v3/webhooks/billing/' + tenant.slug}
Última sync hace 4m
) : }
{pagos.length === 0 ? : (
{pagos.map(p => (
{p.name.slice(0, 2).toUpperCase()}
{p.name}
{p.currency} · {p.authMode} · {p.latency || '—'}ms
))}
)}
{tenant.channels.map(c => (
{window.CHANNEL_LABEL[c]}
{c === 'whatsapp' ? 'phone_number_id=104982304' : c === 'telegram' ? 'bot @' + tenant.slug + 'bot' : 'workspace #' + tenant.slug + '-soporte'}
))}
POST https://erp.{tenant.slug}.com/webhooks/blaze/pagos · 24h: 1,842 · ok
POST https://crm.{tenant.slug}.com/webhooks/blaze/mensajes · 24h: 8,412 · ok
POST https://staging.{tenant.slug}.com/hook · 24h: 142 · 4 fallos
);
}
// Módulos reales del backend (tenant.ModuleFlags) con etiqueta + icono.
const BACKEND_MODULES = [
{ id: 'wacloud', name: 'WhatsApp Cloud', desc: 'Envío/recepción vía Meta Cloud API', icon: 'message-square' },
{ id: 'telegram', name: 'Telegram', desc: 'Bot de Telegram', icon: 'send' },
{ id: 'chatwoot', name: 'Chatwoot', desc: 'Bridge con inbox Chatwoot', icon: 'messages-square' },
{ id: 'mikrowisp', name: 'Mikrowisp', desc: 'Billing/OSS Mikrowisp', icon: 'router' },
{ id: 'wisphub', name: 'WispHub', desc: 'Billing/OSS WispHub', icon: 'router' },
{ id: 'domiisp', name: 'DomiISP', desc: 'Billing/OSS DomiISP', icon: 'router' },
{ id: 'oficable', name: 'Oficable', desc: 'Billing/OSS Oficable', icon: 'router' },
{ id: 'smartolt', name: 'SmartOLT', desc: 'Gestión de OLTs', icon: 'network' },
{ id: 'voltage', name: 'Voltage', desc: 'Monitoreo de voltaje', icon: 'zap' },
{ id: 'cardnet', name: 'CardNET', desc: 'Pasarela de pagos CardNET', icon: 'credit-card' },
{ id: 'azul', name: 'Azul', desc: 'Pasarela Azul (Popular)', icon: 'credit-card' },
{ id: 'paypal', name: 'PayPal', desc: 'Pasarela PayPal', icon: 'credit-card' },
{ id: 'cybersource', name: 'CyberSource', desc: 'Procesador Visa/CyberSource', icon: 'credit-card' },
{ id: 'monitoring', name: 'Monitoring', desc: 'Ingesta de alertas NMS (PRTG…)', icon: 'activity' },
{ id: 'blazechat', name: 'BlazeChat', desc: 'Chat interno Blaze', icon: 'message-circle' },
{ id: 'blazeteams', name: 'BlazeTeams', desc: 'Notificaciones a equipos', icon: 'users' },
{ id: 'blazebot', name: 'BlazeBot', desc: 'Agente/bot Blaze', icon: 'bot' }
];
function TenantModules({ tenant }) {
const live = tenant._live === true;
const [saving, setSaving] = _as(null); // id del módulo guardando
const [err, setErr] = _as(null);
// Demo: catálogo cosmético original; Live: flags reales del backend.
if (!live) {
const all = [
{ id: 'mensajeria', name: 'Mensajería', desc: 'Inbox unificado, plantillas WhatsApp, broadcasts', icon: 'message-square' },
{ id: 'billing', name: 'Billing sync', desc: 'Sincroniza clientes, facturas y servicios con el OSS', icon: 'file-text' },
{ id: 'pagos', name: 'Pagos', desc: 'Procesa pagos online y reconcilia con el OSS', icon: 'credit-card' },
{ id: 'automation', name: 'Automation', desc: 'Workflows: corte automático, recordatorios, escalado', icon: 'workflow' },
{ id: 'ai_agent', name: 'AI Agent', desc: 'Agente IA para soporte L1 (BlazeAI)', icon: 'sparkles' },
{ id: 'campaigns', name: 'Campañas', desc: 'Envío masivo segmentado (requiere plan Pro)', icon: 'megaphone' }
];
return {all.map(m => )}
;
}
const flags = tenant.modulesRaw || {};
async function toggle(id) {
setSaving(id); setErr(null);
try {
const next = Object.assign({}, flags, { [id]: !flags[id] });
await window.BC.updateModules(tenant.uuid, next);
} catch (e) { setErr(e.message); }
finally { setSaving(null); }
}
return (
<>
{err && {err}
}
{BACKEND_MODULES.map(m => toggle(m.id)} />)}
>
);
}
function ModuleCard({ m, on, saving, onToggle }) {
return (
{m.name}
{saving ? : }
{m.desc}
);
}
function Toggle({ on }) {
return (
);
}
// Scopes reales del control plane (admin:* = wildcard).
const SCOPE_OPTIONS = ['messaging:read', 'messaging:write', 'payments:read', 'payments:write', 'isp:read', 'isp:write', 'billing:read', 'network:read', 'blazehub:read', 'blazehub:write', 'admin:*'];
function TenantApiKeys({ tenant, role }) {
const bc = window.useBC();
const live = tenant._live === true;
const [issuing, setIssuing] = _as(false);
const [rawKey, setRawKey] = _as(null);
const [revoking, setRevoking] = _as(null);
// Demo: keys mock filtradas. Live: carga real on-mount.
_ae(() => { if (live && !bc.keysByTenant[tenant.uuid]) window.BC.loadKeys(tenant.uuid).catch(() => {}); }, [live, tenant.uuid]);
const slot = live ? (bc.keysByTenant[tenant.uuid] || { loading: true, data: [] }) : { loading: false, data: window.API_KEYS.filter(k => k.tenant === tenant.id) };
const keys = slot.data || [];
const canCreate = window.hasPerm(role, 'apikey:create');
const columns = live ? keyColumnsLive((k) => setRevoking(k)) : ApiKeyColumns;
return (
setIssuing(true)}> Emitir key}>
{slot.loading ?
: slot.error ?
: keys.length === 0 ?
: }
{issuing && setIssuing(false)} onIssued={(d) => { setIssuing(false); setRawKey(d); }} />}
{rawKey && setRawKey(null)} />}
{revoking && setRevoking(null)} onConfirm={() => window.BC.revokeKey(tenant.uuid, revoking.id).catch(() => {})} />}
);
}
// Columnas para keys reales (sin IP/preview inventados; revocar real).
function keyColumnsLive(onRevoke) {
return [
{ label: 'Key', render: (k) => (
) },
{ label: 'Scopes', render: (k) => {k.scopes.map(s => {s} )}
},
{ label: 'Creada', width: 130, render: (k) => hace {window.timeAgo(k.createdAt)} },
{ label: 'Último uso', width: 130, render: (k) => hace {window.timeAgo(k.lastUsed)} },
{ label: 'Estado', width: 100, render: (k) => },
{ label: '', width: 90, render: (k) => (
{k.status === 'active' && onRevoke(k)}>Revocar }
) }
];
}
// Modal de emisión real (POST /admin/tenants/:uuid/apikeys).
// Si `tenant` es null y se pasa `tenants`, muestra selector de tenant (vista global).
function IssueKeyModal({ tenant, tenants, live, onClose, onIssued }) {
const [name, setName] = _as('');
const [scopes, setScopes] = _as(['messaging:read', 'messaging:write']);
const [selUuid, setSelUuid] = _as(tenant ? tenant.uuid : ((tenants && tenants[0] && tenants[0].uuid) || ''));
const [busy, setBusy] = _as(false);
const [err, setErr] = _as(null);
function toggleScope(s) { setScopes(p => p.includes(s) ? p.filter(x => x !== s) : [...p, s]); }
async function submit() {
const uuid = tenant ? tenant.uuid : selUuid;
if (!uuid) { setErr('Selecciona un tenant'); return; }
if (!name.trim() || scopes.length === 0) { setErr('Nombre y al menos un scope'); return; }
if (!live) { onIssued({ raw_token: 'bk_demo_' + window.randHex(20), name: name.trim(), scopes, created_at: new Date().toISOString() }, uuid); return; }
setBusy(true); setErr(null);
try { onIssued(await window.BC.issueKey(uuid, { name: name.trim(), scopes }), uuid); }
catch (e) { setErr(e.message); setBusy(false); }
}
return (
Cancelar {busy ? 'Emitiendo…' : 'Emitir key'} >}>
{!tenant && tenants && (
Tenant
setSelUuid(e.target.value)} style={{ background: 'var(--app-input-bg)', border: '1px solid var(--app-border)', borderRadius: 6, padding: '8px 10px', color: 'var(--app-fg1)', fontFamily: 'inherit', fontSize: 13 }}>
{tenants.map(t => {t.name} )}
)}
Nombre descriptivo
setName(e.target.value)} autoFocus placeholder="ej. Production · WhatsApp dispatcher" style={{ background: 'var(--app-input-bg)', border: '1px solid var(--app-border)', borderRadius: 6, padding: '8px 10px', color: 'var(--app-fg1)', fontFamily: 'inherit', fontSize: 13 }} />
Scopes ({scopes.length})
{SCOPE_OPTIONS.map(s => toggleScope(s)}>{s} )}
{err &&
{err}
}
);
}
// Muestra el token RAW una sola vez (raw_token de la respuesta real).
function RawKeyModal({ data, tenant, onClose }) {
const raw = data.raw_token || data.raw || '';
return (
navigator.clipboard && navigator.clipboard.writeText(raw)}> Copiar Listo, la guardé >}>
No podemos recuperar esta key más adelante. Guárdala en tu gestor de secretos.
Nombre {data.name}
{tenant && <>Tenant {tenant.name} >}
Scopes {(data.scopes || []).join(', ')}
);
}
function TenantTemplates({ tenant }) {
const bc = window.useBC();
const live = tenant._live === true;
const [syncing, setSyncing] = _as(false);
_ae(() => { if (live && !bc.templatesByTenant[tenant.uuid]) window.BC.loadTemplates(tenant.uuid).catch(() => {}); }, [live, tenant.uuid]);
const slot = live ? (bc.templatesByTenant[tenant.uuid] || { loading: true, data: [] }) : { loading: false, data: window.TEMPLATES };
const tpls = slot.data || [];
async function sync() { setSyncing(true); try { await window.BC.syncTemplates(tenant.uuid); } catch (e) {} finally { setSyncing(false); } }
return (
{syncing ? 'Sincronizando…' : 'Sync ahora'}}>
{slot.loading ?
: slot.error ?
: tpls.length === 0 ?
: {t.name} },
{ label: 'Categoría', width: 140, render: (t) => {t.category} },
{ label: 'Idioma', width: 80, render: (t) => {t.lang} },
{ label: 'Estado', width: 130, render: (t) => }
]} rows={tpls} />}
);
}
function TenantAudit({ tenant }) {
const events = window.AUDIT_EVENTS.filter(a => a.tenant === tenant.id);
return (
{events.length === 0
?
: }
);
}
/* =========================================================================
API KEYS — global
========================================================================= */
const ApiKeyColumns = [
{ label: 'Key', render: (k) => (
) },
{ label: 'Tenant', width: 160, render: (k) => {k.tenantName} },
{ label: 'Scopes', render: (k) => {k.scopes.map(s => {s} )}
},
{ label: 'Última IP', width: 130, render: (k) => {k.lastIp} },
{ label: 'Último uso', width: 130, render: (k) => hace {window.timeAgo(k.lastUsed)} },
{ label: 'Estado', width: 100, render: (k) => },
{ label: '', width: 80, render: (k) => (
{k.status === 'active' && Revocar }
) }
];
function PageApiKeys({ tenant, role }) {
const bc = window.useBC();
const live = bc.mode === 'live';
const [issuing, setIssuing] = _as(false);
const [rawKey, setRawKey] = _as(null);
const [revoking, setRevoking] = _as(null);
const [search, setSearch] = _as('');
// Tenants en alcance (uno o todos).
const scope = _am(() => tenant.id === '*' ? bc.tenants : bc.tenants.filter(t => t.id === tenant.id), [tenant, bc.tenants]);
// Live: carga keys de cada tenant en alcance (una vez).
_ae(() => {
if (!live) return;
scope.forEach(t => { if (!bc.keysByTenant[t.uuid]) window.BC.loadKeys(t.uuid).catch(() => {}); });
}, [live, scope]);
const rows = _am(() => {
const ql = search.toLowerCase();
let all;
if (live) {
all = [];
scope.forEach(t => { (bc.keysByTenant[t.uuid] || {}).data && (bc.keysByTenant[t.uuid].data).forEach(k => all.push(k)); });
} else {
all = window.API_KEYS.filter(k => tenant.id === '*' || k.tenant === tenant.id);
}
return all.filter(k => !ql || (k.name + ' ' + k.tenantName + ' ' + k.id).toLowerCase().includes(ql));
}, [live, scope, search, bc.keysByTenant]);
const loading = live && scope.some(t => (bc.keysByTenant[t.uuid] || {}).loading) && rows.length === 0;
const columns = live ? [
{ label: 'Key', render: (k) => },
{ label: 'Tenant', width: 180, render: (k) => {k.tenantName} },
{ label: 'Scopes', render: (k) => {k.scopes.map(s => {s} )}
},
{ label: 'Último uso', width: 120, render: (k) => hace {window.timeAgo(k.lastUsed)} },
{ label: 'Estado', width: 90, render: (k) => },
{ label: '', width: 90, render: (k) => k.status === 'active' ? setRevoking(k)}>Revocar
: null }
] : ApiKeyColumns;
return (
API Keys {live && live }
{rows.length} keys · {rows.filter(k => k.status === 'active').length} activas · {rows.filter(k => k.status === 'revoked').length} revocadas{tenant.id !== '*' && ' · ' + tenant.name}
{window.hasPerm(role, 'apikey:create') && setIssuing(true)} disabled={live && scope.length === 0}> Emitir API key }
Las keys solo se muestran una vez al emitirlas.
Cópiala a tu gestor de secretos antes de cerrar el modal — no hay forma de recuperarla luego.
{loading ?
: }
{issuing &&
setIssuing(false)} onIssued={(d) => { setIssuing(false); setRawKey(d); }} />}
{rawKey && setRawKey(null)} />}
{revoking && setRevoking(null)} onConfirm={() => window.BC.revokeKey(revoking.tenant, revoking.id).catch(() => {})} />}
);
}
/* =========================================================================
TEMPLATES (global)
========================================================================= */
function PageTemplates({ tenant }) {
return (
Templates WhatsApp
{window.TEMPLATES.length} plantillas · sincronizadas con Meta Business Manager
Sync con Meta
Nueva plantilla
t.status === 'APPROVED').length} color="green" />
t.status === 'PENDING').length} color="warn" />
t.status === 'REJECTED').length} color="danger" foot="revisa políticas de Meta" />
{}} placeholder="Buscar plantilla…" />
{t.name}
{t.name === 'factura_lista_ES' ? 'Su factura de {{1}} está lista. Monto: {{2}}. Vence el {{3}}.' : t.name === 'pago_recibido_ES' ? 'Confirmamos su pago de {{1}}. Gracias.' : 'Plantilla operacional'}
},
{ label: 'Categoría', width: 140, render: (t) => {t.category} },
{ label: 'Idioma', width: 80, render: (t) => {t.lang} },
{ label: 'Estado', width: 130, render: (t) => },
{ label: 'Tenants usando', width: 130, numeric: true, render: () => {Math.floor(Math.random() * 8) + 1} },
{ label: 'Envíos 24h', width: 100, numeric: true, render: () => {(Math.floor(Math.random() * 5000) + 200).toLocaleString()} },
{ label: 'Última sync', width: 130, render: () => hace {Math.floor(Math.random() * 30) + 1}m }
]} rows={window.TEMPLATES} />
);
}
/* =========================================================================
AUDITORÍA
========================================================================= */
const ACTION_TONE = {
'tenant.created': { tone: 'success', ico: 'plus' },
'tenant.updated': { tone: 'info', ico: 'pencil' },
'apikey.created': { tone: 'success', ico: 'key-round' },
'apikey.revoked': { tone: 'danger', ico: 'key-round' },
'config.updated': { tone: 'info', ico: 'settings' },
'template.synced': { tone: 'info', ico: 'refresh-cw' },
'template.rejected': { tone: 'danger', ico: 'x' },
'retry.executed': { tone: 'warn', ico: 'rotate-cw' },
'message.viewed': { tone: 'info', ico: 'eye' }
};
function AuditTable({ events }) {
return (
{window.fmtDateTime(a.ts)} },
{ label: 'Acción', width: 240, render: (a) => {
const c = ACTION_TONE[a.action] || { tone: 'neutral', ico: 'circle' };
return
{a.action}
;
} },
{ label: 'Actor', width: 220, render: (a) => (
{a.actor.type === 'user'
?
: }
{a.actor.name}
{a.actor.role && · {a.actor.role} }
) },
{ label: 'Target', render: (a) => {a.target} },
{ label: 'Tenant', width: 160, render: (a) => a.tenant
? t.id === a.tenant) || {}).name || a.tenant }} size={16} /> {(window.TENANTS.find(t => t.id === a.tenant) || {}).name || a.tenant}
: global },
{ label: 'Detalles', render: (a) => {Object.entries(a.meta).slice(0, 2).map(([k, v]) => k + '=' + (Array.isArray(v) ? v.length + ' items' : String(v))).join(' · ')} }
]} rows={events} />
);
}
function PageAudit({ tenant }) {
const [search, setSearch] = _as('');
const rows = _am(() => {
const ql = search.toLowerCase();
return window.AUDIT_EVENTS.filter(a => {
if (tenant.id !== '*' && a.tenant && a.tenant !== tenant.id) return false;
if (ql && !(a.action + ' ' + a.actor.name + ' ' + a.target).toLowerCase().includes(ql)) return false;
return true;
});
}, [search, tenant]);
return (
Auditoría
Registro inmutable · {rows.length} eventos · retención 365 días
Últimos 7 días
Export JSONL
Compliance report
);
}
Object.assign(window, { PageTenants, PageTenantDetail, PageApiKeys, PageTemplates, PageAudit, AuditTable });