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:
@@ -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)" />
|
||||
|
||||
Reference in New Issue
Block a user