Heartbeat WhatsApp Evolution (Grupo 6.1): detecção + incident + alerta admin

Detecta celular desconectado antes de falhar envios silenciosamente.

Banco (migration 20260423000002):
- Tabela whatsapp_connection_incidents (tenant_id, channel_id, kind,
  started_at, resolved_at, duration_seconds, notified_at, details).
  UNIQUE parcial garante no máximo 1 incident aberto por channel.
- RPCs whatsapp_heartbeat_open_incident (idempotente), _resolve_open_incidents
  e _mark_notified. Service_role only.
- RLS: membros do tenant leem, saas_admin tudo.
- ALTER notifications.type pra aceitar 'system_alert' (usado pelo alerta).

Edge function whatsapp-heartbeat-check:
- Varre notification_channels provider=evolution_api e ativos.
- GET {api_url}/instance/connectionState/{instance} (timeout 8s, rewrite
  localhost → host.docker.internal pra containers).
- Mapeia state pra connection_status (open/connecting/qr_pending/
  disconnected/error), persiste + last_health_check.
- Lógica de threshold: marca first_unhealthy_at em metadata na primeira
  falha; só abre incident após heartbeat_threshold_minutes (default 5).
- Notifica admins ativos (clinic_admin/tenant_admin) do tenant via
  insert em notifications. Anti-spam: só notifica 1x por incident.
- Aceita ?channel_id=X pra check on-demand de um tenant específico.

UI tenant (/configuracoes/whatsapp-pessoal):
- Novo card "Monitoramento de conexão" com toggle alerts_enabled +
  InputNumber threshold (1-60 min). Persiste em
  notification_channels.metadata.
- Histórico últimos 7 dias: kind (tag colorida), aberto/resolvido,
  início → fim, duração formatada (Ns/Xmin Ys/Nh Xmin).

UI SaaS (/saas/whatsapp):
- Badge "N incidents abertos" no header quando há algum.
- Botão "Verificar tudo agora" invoca a edge function e atualiza a lista.
- Tabela enriquecida: coluna Status ganha pill "Incident aberto",
  colunas novas Última check e Incidents 7d (em laranja se > 0).

Cron template no final da migration (comentado — descomentar
cron.schedule pra ativar 2min periódico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-23 07:49:09 -03:00
parent f76a2e3033
commit e1f756ea82
4 changed files with 832 additions and 13 deletions
+62 -12
View File
@@ -352,13 +352,29 @@ function closeQrDialog() {
// ── Visão geral: todos os canais WhatsApp ─────────────────────
const allChannels = ref([]);
const loadingAll = ref(false);
const openIncidentsByChannel = ref({}); // { channel_id: incident_row }
const incidents7dByChannel = ref({}); // { channel_id: count }
const heartbeatRunning = ref(false);
async function loadAllChannels() {
loadingAll.value = true;
try {
const { data, error } = await supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false });
if (error) throw error;
allChannels.value = data || [];
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
const [channelsRes, openRes, countRes] = await Promise.all([
supabase.from('notification_channels').select('*').eq('channel', 'whatsapp').is('deleted_at', null).order('created_at', { ascending: false }),
supabase.from('whatsapp_connection_incidents').select('id, channel_id, kind, started_at, last_state').is('resolved_at', null),
supabase.from('whatsapp_connection_incidents').select('channel_id').gte('started_at', sevenDaysAgo)
]);
if (channelsRes.error) throw channelsRes.error;
allChannels.value = channelsRes.data || [];
const openMap = {};
for (const inc of openRes.data || []) openMap[inc.channel_id] = inc;
openIncidentsByChannel.value = openMap;
const countMap = {};
for (const row of countRes.data || []) countMap[row.channel_id] = (countMap[row.channel_id] || 0) + 1;
incidents7dByChannel.value = countMap;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar canais', detail: e.message, life: 4000 });
} finally {
@@ -366,6 +382,25 @@ async function loadAllChannels() {
}
}
async function runHeartbeatNow() {
heartbeatRunning.value = true;
try {
const { data, error } = await supabase.functions.invoke('whatsapp-heartbeat-check', { body: {} });
if (error) throw error;
toast.add({
severity: 'success',
summary: 'Heartbeat executado',
detail: `${data?.checked || 0} canais: ${data?.ok || 0} ok · ${data?.opened || 0} novos incidents · ${data?.resolved || 0} resolvidos`,
life: 4500
});
await loadAllChannels();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || 'Falha ao invocar heartbeat', life: 5000 });
} finally {
heartbeatRunning.value = false;
}
}
function channelStatusTag(ch) {
if (!ch.is_active) return { label: 'Desativado', severity: 'secondary' };
switch (ch.connection_status) {
@@ -430,9 +465,14 @@ onBeforeUnmount(() => {
<!-- ABA 1 Visão geral -->
<TabPanel :value="0">
<div class="flex flex-col gap-3 pt-3">
<div class="flex items-center gap-2">
<span class="text-sm text-[var(--text-color-secondary)]"> {{ allChannels.length }} canal(is) WhatsApp configurado(s) </span>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="loadingAll" v-tooltip.bottom="'Atualizar'" class="ml-auto" @click="loadAllChannels" />
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm text-[var(--text-color-secondary)]">{{ allChannels.length }} canal(is) WhatsApp configurado(s)</span>
<span v-if="Object.keys(openIncidentsByChannel).length" class="inline-flex items-center gap-1 text-xs font-semibold text-red-600 bg-red-500/10 px-2 py-0.5 rounded-full">
<i class="pi pi-exclamation-circle" />
{{ Object.keys(openIncidentsByChannel).length }} incident(s) aberto(s)
</span>
<Button icon="pi pi-heart-fill" label="Verificar tudo agora" size="small" severity="warn" outlined :loading="heartbeatRunning" class="ml-auto" @click="runHeartbeatNow" />
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="loadingAll" v-tooltip.bottom="'Recarregar'" @click="loadAllChannels" />
</div>
<DataTable :value="allChannels" :loading="loadingAll" responsive-layout="scroll" striped-rows class="text-sm">
@@ -451,7 +491,22 @@ onBeforeUnmount(() => {
</Column>
<Column header="Status" style="min-width: 120px">
<template #body="{ data }">
<Tag :value="channelStatusTag(data).label" :severity="channelStatusTag(data).severity" class="text-[0.7rem]" />
<div class="flex flex-col gap-1">
<Tag :value="channelStatusTag(data).label" :severity="channelStatusTag(data).severity" class="text-[0.7rem]" />
<Tag v-if="openIncidentsByChannel[data.id]" value="Incident aberto" severity="danger" class="text-[0.6rem]" />
</div>
</template>
</Column>
<Column header="Última check" style="min-width: 140px">
<template #body="{ data }">
<span class="text-xs font-mono text-[var(--text-color-secondary)]">{{ data.last_health_check ? formatDate(data.last_health_check) : '—' }}</span>
</template>
</Column>
<Column header="Incidents 7d" style="min-width: 100px">
<template #body="{ data }">
<span class="text-xs font-mono" :class="(incidents7dByChannel[data.id] || 0) > 0 ? 'text-orange-600 font-bold' : 'text-[var(--text-color-secondary)]'">
{{ incidents7dByChannel[data.id] || 0 }}
</span>
</template>
</Column>
<Column header="Ativo" style="min-width: 80px">
@@ -459,11 +514,6 @@ onBeforeUnmount(() => {
<Tag :value="data.is_active ? 'Sim' : 'Não'" :severity="data.is_active ? 'success' : 'secondary'" class="text-[0.7rem]" />
</template>
</Column>
<Column header="Criado em" style="min-width: 140px">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column header="" style="width: 60px">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" v-tooltip.left="'Configurar'" @click="selectTenantFromTable(data.tenant_id)" />