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:
@@ -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">
|
||||
|
||||
@@ -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