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
@@ -152,6 +152,99 @@ async function checkConnectionStatus() {
}
}
// ──────────────────────────────────────────────────────────────
// Monitoramento de conexão (Heartbeat — Grupo 6.1)
// ──────────────────────────────────────────────────────────────
const heartbeatConfig = ref({ threshold_minutes: 5, alerts_enabled: true });
const heartbeatConfigSaving = ref(false);
const incidents = ref([]);
const incidentsLoading = ref(false);
async function loadHeartbeatConfig() {
if (!channelRecord.value) return;
const meta = channelRecord.value.metadata || {};
heartbeatConfig.value = {
threshold_minutes: Number(meta.heartbeat_threshold_minutes) || 5,
alerts_enabled: meta.heartbeat_alerts_enabled !== false
};
}
async function saveHeartbeatConfig() {
if (!channelRecord.value?.id) return;
heartbeatConfigSaving.value = true;
try {
const threshold = Math.max(1, Math.min(60, Math.round(Number(heartbeatConfig.value.threshold_minutes) || 5)));
const newMeta = {
...(channelRecord.value.metadata || {}),
heartbeat_threshold_minutes: threshold,
heartbeat_alerts_enabled: !!heartbeatConfig.value.alerts_enabled
};
const { error } = await supabase
.from('notification_channels')
.update({ metadata: newMeta })
.eq('id', channelRecord.value.id);
if (error) throw error;
channelRecord.value.metadata = newMeta;
heartbeatConfig.value.threshold_minutes = threshold;
toast.add({ severity: 'success', summary: 'Configuração salva', life: 2000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
} finally {
heartbeatConfigSaving.value = false;
}
}
async function loadIncidents() {
if (!tenantId.value || !channelRecord.value?.id) return;
incidentsLoading.value = true;
try {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
const { data, error } = await supabase
.from('whatsapp_connection_incidents')
.select('id, kind, last_state, started_at, resolved_at, duration_seconds, notified_at')
.eq('channel_id', channelRecord.value.id)
.gte('started_at', sevenDaysAgo)
.order('started_at', { ascending: false })
.limit(30);
if (error) throw error;
incidents.value = data || [];
} catch (e) {
toast.add({ severity: 'warn', summary: 'Histórico indisponível', detail: e.message, life: 3000 });
} finally {
incidentsLoading.value = false;
}
}
function formatDuration(seconds) {
if (!seconds && seconds !== 0) return '—';
const s = Number(seconds);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}min ${s % 60}s`;
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
return `${h}h ${m}min`;
}
function incidentKindLabel(kind) {
return {
disconnected: 'Desconectado',
error: 'Erro',
qr_pending: 'Aguardando QR',
connecting: 'Conectando',
unknown: 'Desconhecido'
}[kind] || kind;
}
function incidentKindSeverity(kind) {
return {
disconnected: 'danger',
error: 'danger',
qr_pending: 'warn',
connecting: 'info',
unknown: 'secondary'
}[kind] || 'secondary';
}
// Buscar QR Code para conectar
async function fetchQrCode() {
if (!isMounted) return;
@@ -593,7 +686,11 @@ function onPageChange(event) {
onMounted(async () => {
await loadUser();
await Promise.all([loadCredentials(), loadTemplates(), loadLogs()]);
if (hasCredentials.value) await checkConnectionStatus();
if (hasCredentials.value) {
await checkConnectionStatus();
await loadHeartbeatConfig();
await loadIncidents();
}
});
onBeforeUnmount(() => {
@@ -664,6 +761,66 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Monitoramento de conexão (heartbeat) -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)] flex flex-col gap-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-10 h-10 rounded-full bg-amber-100 text-amber-600">
<i class="pi pi-heart-fill text-lg" />
</div>
<div>
<div class="font-semibold text-sm">Monitoramento de conexão</div>
<div class="text-xs text-[var(--text-color-secondary)]">
Recebe alerta quando o celular fica offline por mais tempo que o configurado.
</div>
</div>
</div>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="incidentsLoading" v-tooltip.bottom="'Recarregar histórico'" @click="loadIncidents" />
</div>
<div class="grid grid-cols-1 md:grid-cols-[auto_auto_1fr] gap-3 items-end">
<div class="flex items-center gap-2">
<ToggleSwitch v-model="heartbeatConfig.alerts_enabled" inputId="hb-alerts" />
<label for="hb-alerts" class="text-sm cursor-pointer select-none">Alertas ativos</label>
</div>
<div class="flex items-center gap-2">
<label class="text-sm whitespace-nowrap">Alertar após</label>
<InputNumber v-model="heartbeatConfig.threshold_minutes" :min="1" :max="60" showButtons buttonLayout="horizontal" :inputStyle="{ width: '3.5rem', textAlign: 'center' }" incrementButtonIcon="pi pi-plus" decrementButtonIcon="pi pi-minus" />
<span class="text-sm text-[var(--text-color-secondary)]">min sem conexão</span>
</div>
<div class="flex justify-end">
<Button label="Salvar" icon="pi pi-check" size="small" :loading="heartbeatConfigSaving" @click="saveHeartbeatConfig" />
</div>
</div>
<!-- Histórico de incidents -->
<div class="border-t border-[var(--surface-border)] pt-3">
<div class="text-xs font-semibold uppercase tracking-wide text-[var(--text-color-secondary)] mb-2">
Últimos 7 dias ({{ incidents.length }} {{ incidents.length === 1 ? 'evento' : 'eventos' }})
</div>
<div v-if="incidentsLoading" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">
Carregando
</div>
<div v-else-if="!incidents.length" class="text-xs text-[var(--text-color-secondary)] italic py-3 text-center">
Nenhum evento registrado nos últimos 7 dias conexão estável.
</div>
<div v-else class="flex flex-col gap-1 max-h-[240px] overflow-y-auto text-xs">
<div v-for="inc in incidents" :key="inc.id"
class="grid grid-cols-[auto_auto_1fr_auto] items-center gap-3 px-2 py-1.5 rounded hover:bg-[var(--surface-hover)]">
<Tag :value="incidentKindLabel(inc.kind)" :severity="incidentKindSeverity(inc.kind)" class="text-[0.62rem]" />
<span :class="inc.resolved_at ? 'text-green-600' : 'text-orange-600'" class="text-[0.62rem] font-bold uppercase">
{{ inc.resolved_at ? 'Resolvido' : 'Aberto' }}
</span>
<span class="text-[var(--text-color-secondary)]">
<span class="font-mono">{{ new Date(inc.started_at).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) }}</span>
<span v-if="inc.resolved_at"> {{ new Date(inc.resolved_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) }}</span>
</span>
<span class="text-[var(--text-color-secondary)] font-mono">{{ formatDuration(inc.duration_seconds) }}</span>
</div>
</div>
</div>
</div>
<!-- Inbox / CRM de conversas (Fase 5a) -->
<div v-if="connectionStatus === 'open'" class="flex flex-col gap-3 px-4 py-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="flex items-center justify-between gap-3 flex-wrap">