refactor: improve layout and backend monitoring functionality

- Streamlined the layout component in Svelte for better readability and consistency.
- Enhanced the backend monitoring functions by updating argument structures and improving code clarity.
- Added new query functions for system status and database activity, providing better insights into system performance.
- Cleaned up existing code to ensure maintainability and improved error handling across various functions.
This commit is contained in:
2025-11-08 18:30:27 -03:00
parent 5d76c375c2
commit 4ed90d380d
5 changed files with 2737 additions and 2600 deletions

View File

@@ -1,26 +1,38 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { format, subDays, startOfDay, endOfDay } from "date-fns";
import { ptBR } from "date-fns/locale";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
import Papa from "papaparse";
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { format, subDays, startOfDay, endOfDay } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import Papa from 'papaparse';
let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient();
// Estados
let periodType = $state("custom");
let dataInicio = $state(format(subDays(new Date(), 7), "yyyy-MM-dd"));
let dataFim = $state(format(new Date(), "yyyy-MM-dd"));
let horaInicio = $state("00:00");
let horaFim = $state("23:59");
let periodType = $state('custom');
let dataInicio = $state(format(subDays(new Date(), 7), 'yyyy-MM-dd'));
let dataFim = $state(format(new Date(), 'yyyy-MM-dd'));
let horaInicio = $state('00:00');
let horaFim = $state('23:59');
let generating = $state(false);
// Métricas selecionadas
let selectedMetrics = $state({
const metricLabels = {
cpuUsage: 'Uso de CPU (%)',
memoryUsage: 'Uso de Memória (%)',
networkLatency: 'Latência de Rede (ms)',
storageUsed: 'Armazenamento (%)',
usuariosOnline: 'Usuários Online',
mensagensPorMinuto: 'Mensagens/min',
tempoRespostaMedio: 'Tempo Resposta (ms)',
errosCount: 'Erros'
} as const;
type MetricKey = keyof typeof metricLabels;
let selectedMetrics = $state<Record<MetricKey, boolean>>({
cpuUsage: true,
memoryUsage: true,
networkLatency: true,
@@ -28,44 +40,40 @@
usuariosOnline: true,
mensagensPorMinuto: true,
tempoRespostaMedio: true,
errosCount: true,
errosCount: true
});
const metricLabels: Record<string, string> = {
cpuUsage: "Uso de CPU (%)",
memoryUsage: "Uso de Memória (%)",
networkLatency: "Latência de Rede (ms)",
storageUsed: "Armazenamento (%)",
usuariosOnline: "Usuários Online",
mensagensPorMinuto: "Mensagens/min",
tempoRespostaMedio: "Tempo Resposta (ms)",
errosCount: "Erros",
};
const metricEntries = $derived(Object.entries(metricLabels) as Array<[MetricKey, string]>);
function setPeriod(type: string) {
function isMetricSelected(key: MetricKey): boolean {
return selectedMetrics[key];
}
function setMetricSelected(key: MetricKey, value: boolean): void {
selectedMetrics[key] = value;
}
function setPeriod(type: 'today' | 'week' | 'month' | 'custom') {
periodType = type;
const now = new Date();
switch (type) {
case "today":
dataInicio = format(now, "yyyy-MM-dd");
dataFim = format(now, "yyyy-MM-dd");
case 'today':
dataInicio = format(now, 'yyyy-MM-dd');
dataFim = format(now, 'yyyy-MM-dd');
break;
case "week":
dataInicio = format(subDays(now, 7), "yyyy-MM-dd");
dataFim = format(now, "yyyy-MM-dd");
case 'week':
dataInicio = format(subDays(now, 7), 'yyyy-MM-dd');
dataFim = format(now, 'yyyy-MM-dd');
break;
case "month":
dataInicio = format(subDays(now, 30), "yyyy-MM-dd");
dataFim = format(now, "yyyy-MM-dd");
case 'month':
dataInicio = format(subDays(now, 30), 'yyyy-MM-dd');
dataFim = format(now, 'yyyy-MM-dd');
break;
}
}
function getDateRange(): { inicio: number; fim: number } {
const inicio = startOfDay(
new Date(`${dataInicio}T${horaInicio}`),
).getTime();
const inicio = startOfDay(new Date(`${dataInicio}T${horaInicio}`)).getTime();
const fim = endOfDay(new Date(`${dataFim}T${horaFim}`)).getTime();
return { inicio, fim };
}
@@ -77,97 +85,109 @@
const { inicio, fim } = getDateRange();
const relatorio = await client.query(api.monitoramento.gerarRelatorio, {
dataInicio: inicio,
dataFim: fim,
dataFim: fim
});
type Estatistica = { min: number; max: number; avg: number };
const estatPorMetrica = relatorio.estatisticas as unknown as Record<
MetricKey,
Estatistica | undefined
>;
const doc = new jsPDF();
// Título
doc.setFontSize(20);
doc.setTextColor(102, 126, 234); // Primary color
doc.text("Relatório de Monitoramento do Sistema", 14, 20);
doc.text('Relatório de Monitoramento do Sistema', 14, 20);
// Subtítulo com período
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.text(
`Período: ${format(inicio, "dd/MM/yyyy HH:mm", { locale: ptBR })} até ${format(fim, "dd/MM/yyyy HH:mm", { locale: ptBR })}`,
`Período: ${format(inicio, 'dd/MM/yyyy HH:mm', { locale: ptBR })} até ${format(fim, 'dd/MM/yyyy HH:mm', { locale: ptBR })}`,
14,
30,
30
);
// Informações gerais
doc.setFontSize(10);
doc.text(
`Gerado em: ${format(new Date(), "dd/MM/yyyy HH:mm", { locale: ptBR })}`,
14,
38,
);
doc.text(`Gerado em: ${format(new Date(), 'dd/MM/yyyy HH:mm', { locale: ptBR })}`, 14, 38);
doc.text(`Total de registros: ${relatorio.metricas.length}`, 14, 44);
// Estatísticas
let yPos = 55;
doc.setFontSize(14);
doc.setTextColor(102, 126, 234);
doc.text("Estatísticas do Período", 14, yPos);
doc.text('Estatísticas do Período', 14, yPos);
yPos += 10;
const statsData: any[] = [];
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
if (selected && relatorio.estatisticas[metric]) {
const stats = relatorio.estatisticas[metric];
const statsData: string[][] = [];
(Object.entries(selectedMetrics) as Array<[MetricKey, boolean]>).forEach(
([metric, selected]) => {
if (selected && estatPorMetrica[metric]) {
const stats = estatPorMetrica[metric];
if (stats) {
statsData.push([
metricLabels[metric],
stats.min.toFixed(2),
stats.max.toFixed(2),
stats.avg.toFixed(2),
stats.avg.toFixed(2)
]);
}
}
});
}
);
autoTable(doc, {
startY: yPos,
head: [["Métrica", "Mínimo", "Máximo", "Média"]],
head: [['Métrica', 'Mínimo', 'Máximo', 'Média']],
body: statsData,
theme: "striped",
headStyles: { fillColor: [102, 126, 234] },
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] }
});
// Dados detalhados (últimos 50 registros)
const finalY = (doc as any).lastAutoTable.finalY || yPos + 10;
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos + 10;
yPos = finalY + 15;
doc.setFontSize(14);
doc.setTextColor(102, 126, 234);
doc.text("Registros Detalhados (Últimos 50)", 14, yPos);
doc.text('Registros Detalhados (Últimos 50)', 14, yPos);
yPos += 10;
const detailsData = relatorio.metricas.slice(0, 50).map((m) => {
const row = [format(m.timestamp, "dd/MM HH:mm", { locale: ptBR })];
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
const detailsData: string[][] = relatorio.metricas.slice(0, 50).map((m) => {
const row: string[] = [format(m.timestamp, 'dd/MM HH:mm', { locale: ptBR })];
(Object.entries(selectedMetrics) as Array<[MetricKey, boolean]>).forEach(
([metric, selected]) => {
if (selected) {
row.push((m[metric] || 0).toFixed(1));
const value = (m as unknown as Record<MetricKey, number | undefined>)[metric] ?? 0;
row.push(value.toFixed(1));
}
});
}
);
return row;
});
const headers = ["Data/Hora"];
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
const headers = ['Data/Hora'];
(Object.entries(selectedMetrics) as Array<[MetricKey, boolean]>).forEach(
([metric, selected]) => {
if (selected) {
headers.push(metricLabels[metric]);
}
});
}
);
autoTable(doc, {
startY: yPos,
head: [headers],
body: detailsData,
theme: "grid",
theme: 'grid',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 8 },
styles: { fontSize: 8 }
});
// Footer
@@ -180,17 +200,15 @@
`SGSE - Sistema de Gestão da Secretaria de Esportes | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 10,
{ align: "center" },
{ align: 'center' }
);
}
// Salvar
doc.save(
`relatorio-monitoramento-${format(new Date(), "yyyy-MM-dd-HHmm")}.pdf`,
);
doc.save(`relatorio-monitoramento-${format(new Date(), 'yyyy-MM-dd-HHmm')}.pdf`);
} catch (error) {
console.error("Erro ao gerar PDF:", error);
alert("Erro ao gerar relatório PDF. Tente novamente.");
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar relatório PDF. Tente novamente.');
} finally {
generating = false;
}
@@ -203,22 +221,25 @@
const { inicio, fim } = getDateRange();
const relatorio = await client.query(api.monitoramento.gerarRelatorio, {
dataInicio: inicio,
dataFim: fim,
dataFim: fim
});
// Preparar dados para CSV
const csvData = relatorio.metricas.map((m) => {
const row: any = {
"Data/Hora": format(m.timestamp, "dd/MM/yyyy HH:mm:ss", {
locale: ptBR,
}),
const row: Record<string, string | number> = {
'Data/Hora': format(m.timestamp, 'dd/MM/yyyy HH:mm:ss', {
locale: ptBR
})
};
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
(Object.entries(selectedMetrics) as Array<[MetricKey, boolean]>).forEach(
([metric, selected]) => {
if (selected) {
row[metricLabels[metric]] = m[metric] || 0;
row[metricLabels[metric]] =
(m as unknown as Record<MetricKey, number | undefined>)[metric] ?? 0;
}
});
}
);
return row;
});
@@ -227,97 +248,85 @@
const csv = Papa.unparse(csvData);
// Download
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute('href', url);
link.setAttribute(
"download",
`relatorio-monitoramento-${format(new Date(), "yyyy-MM-dd-HHmm")}.csv`,
'download',
`relatorio-monitoramento-${format(new Date(), 'yyyy-MM-dd-HHmm')}.csv`
);
link.style.visibility = "hidden";
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error("Erro ao gerar CSV:", error);
alert("Erro ao gerar relatório CSV. Tente novamente.");
console.error('Erro ao gerar CSV:', error);
alert('Erro ao gerar relatório CSV. Tente novamente.');
} finally {
generating = false;
}
}
function toggleAllMetrics(value: boolean) {
Object.keys(selectedMetrics).forEach((key) => {
(Object.keys(selectedMetrics) as MetricKey[]).forEach((key) => {
selectedMetrics[key] = value;
});
}
</script>
<dialog class="modal modal-open">
<div class="modal-box max-w-3xl bg-linear-to-br from-base-100 to-base-200">
<div class="modal-box from-base-100 to-base-200 max-w-3xl bg-linear-to-br">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
onclick={onClose}
>
</button>
<h3 class="font-bold text-3xl text-primary mb-2">
📊 Gerador de Relatórios
</h3>
<p class="text-base-content/60 mb-6">
Exporte dados de monitoramento em PDF ou CSV
</p>
<h3 class="text-primary mb-2 text-3xl font-bold">📊 Gerador de Relatórios</h3>
<p class="text-base-content/60 mb-6">Exporte dados de monitoramento em PDF ou CSV</p>
<!-- Seleção de Período -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h4 class="card-title text-xl">Período</h4>
<!-- Botões de Período Rápido -->
<div class="flex gap-2 mb-4">
<div class="mb-4 flex gap-2">
<button
type="button"
class="btn btn-sm {periodType === 'today'
? 'btn-primary'
: 'btn-outline'}"
onclick={() => setPeriod("today")}
class="btn btn-sm {periodType === 'today' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('today')}
>
Hoje
</button>
<button
type="button"
class="btn btn-sm {periodType === 'week'
? 'btn-primary'
: 'btn-outline'}"
onclick={() => setPeriod("week")}
class="btn btn-sm {periodType === 'week' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('week')}
>
Última Semana
</button>
<button
type="button"
class="btn btn-sm {periodType === 'month'
? 'btn-primary'
: 'btn-outline'}"
onclick={() => setPeriod("month")}
class="btn btn-sm {periodType === 'month' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('month')}
>
Último Mês
</button>
<button
type="button"
class="btn btn-sm {periodType === 'custom'
? 'btn-primary'
: 'btn-outline'}"
onclick={() => (periodType = "custom")}
class="btn btn-sm {periodType === 'custom' ? 'btn-primary' : 'btn-outline'}"
onclick={() => (periodType = 'custom')}
>
Personalizado
</button>
</div>
{#if periodType === "custom"}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#if periodType === 'custom'}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label" for="dataInicio">
<span class="label-text font-semibold">Data Início</span>
@@ -371,9 +380,9 @@
</div>
<!-- Seleção de Métricas -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div class="mb-4 flex items-center justify-between">
<h4 class="card-title text-xl">Métricas a Incluir</h4>
<div class="flex gap-2">
<button
@@ -393,15 +402,17 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Object.entries(metricLabels) as [metric, label]}
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each metricEntries as [metric, label] (metric)}
<label
class="label cursor-pointer justify-start gap-3 hover:bg-base-200 rounded-lg p-2"
class="label hover:bg-base-200 cursor-pointer justify-start gap-3 rounded-lg p-2"
>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={selectedMetrics[metric]}
checked={isMetricSelected(metric)}
onchange={(e) =>
setMetricSelected(metric, (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="label-text">{label}</span>
</label>
@@ -411,13 +422,8 @@
</div>
<!-- Botões de Exportação -->
<div class="flex gap-3 justify-end">
<button
type="button"
class="btn btn-outline"
onclick={onClose}
disabled={generating}
>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-outline" onclick={onClose} disabled={generating}>
Cancelar
</button>
@@ -480,7 +486,7 @@
<div class="alert alert-warning mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +1,45 @@
<script lang="ts">
import { page } from "$app/state";
import ActionGuard from "$lib/components/ActionGuard.svelte";
import { Toaster } from "svelte-sonner";
import PushNotificationManager from "$lib/components/PushNotificationManager.svelte";
import { page } from '$app/state';
import ActionGuard from '$lib/components/ActionGuard.svelte';
import { Toaster } from 'svelte-sonner';
import PushNotificationManager from '$lib/components/PushNotificationManager.svelte';
const { children } = $props();
// Resolver recurso/ação a partir da rota
const routeAction = $derived.by(() => {
const p = page.url.pathname;
if (p === "/" || p === "/solicitar-acesso") return null;
if (p === '/' || p === '/solicitar-acesso') return null;
// Funcionários
if (p.startsWith("/recursos-humanos/funcionarios")) {
if (p.includes("/cadastro"))
return { recurso: "funcionarios", acao: "criar" };
if (p.includes("/excluir"))
return { recurso: "funcionarios", acao: "excluir" };
if (p.includes("/editar") || p.includes("/funcionarioId"))
return { recurso: "funcionarios", acao: "editar" };
return { recurso: "funcionarios", acao: "listar" };
if (p.startsWith('/recursos-humanos/funcionarios')) {
if (p.includes('/cadastro')) return { recurso: 'funcionarios', acao: 'criar' };
if (p.includes('/excluir')) return { recurso: 'funcionarios', acao: 'excluir' };
if (p.includes('/editar') || p.includes('/funcionarioId'))
return { recurso: 'funcionarios', acao: 'editar' };
return { recurso: 'funcionarios', acao: 'listar' };
}
// Símbolos
if (p.startsWith("/recursos-humanos/simbolos")) {
if (p.includes("/cadastro"))
return { recurso: "simbolos", acao: "criar" };
if (p.includes("/excluir"))
return { recurso: "simbolos", acao: "excluir" };
if (p.includes("/editar") || p.includes("/simboloId"))
return { recurso: "simbolos", acao: "editar" };
return { recurso: "simbolos", acao: "listar" };
if (p.startsWith('/recursos-humanos/simbolos')) {
if (p.includes('/cadastro')) return { recurso: 'simbolos', acao: 'criar' };
if (p.includes('/excluir')) return { recurso: 'simbolos', acao: 'excluir' };
if (p.includes('/editar') || p.includes('/simboloId'))
return { recurso: 'simbolos', acao: 'editar' };
return { recurso: 'simbolos', acao: 'listar' };
}
// Outras áreas (uso genérico: ver)
if (p.startsWith("/financeiro"))
return { recurso: "financeiro", acao: "ver" };
if (p.startsWith("/controladoria"))
return { recurso: "controladoria", acao: "ver" };
if (p.startsWith("/licitacoes"))
return { recurso: "licitacoes", acao: "ver" };
if (p.startsWith("/compras")) return { recurso: "compras", acao: "ver" };
if (p.startsWith("/juridico")) return { recurso: "juridico", acao: "ver" };
if (p.startsWith("/comunicacao"))
return { recurso: "comunicacao", acao: "ver" };
if (p.startsWith("/programas-esportivos"))
return { recurso: "programas_esportivos", acao: "ver" };
if (p.startsWith("/secretaria-executiva"))
return { recurso: "secretaria_executiva", acao: "ver" };
if (p.startsWith("/gestao-pessoas"))
return { recurso: "gestao_pessoas", acao: "ver" };
if (p.startsWith('/financeiro')) return { recurso: 'financeiro', acao: 'ver' };
if (p.startsWith('/controladoria')) return { recurso: 'controladoria', acao: 'ver' };
if (p.startsWith('/licitacoes')) return { recurso: 'licitacoes', acao: 'ver' };
if (p.startsWith('/compras')) return { recurso: 'compras', acao: 'ver' };
if (p.startsWith('/juridico')) return { recurso: 'juridico', acao: 'ver' };
if (p.startsWith('/comunicacao')) return { recurso: 'comunicacao', acao: 'ver' };
if (p.startsWith('/programas-esportivos'))
return { recurso: 'programas_esportivos', acao: 'ver' };
if (p.startsWith('/secretaria-executiva'))
return { recurso: 'secretaria_executiva', acao: 'ver' };
if (p.startsWith('/gestao-pessoas')) return { recurso: 'gestao_pessoas', acao: 'ver' };
return null;
});
@@ -56,12 +47,12 @@
{#if routeAction}
<ActionGuard recurso={routeAction.recurso} acao={routeAction.acao}>
<main id="container-central" class="w-full max-w-none px-3 lg:px-4 py-4">
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</main>
</ActionGuard>
{:else}
<main id="container-central" class="w-full max-w-none px-3 lg:px-4 py-4">
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</main>
{/if}

View File

@@ -1,17 +1,16 @@
<script lang="ts">
import SystemMonitorCardLocal from "$lib/components/ti/SystemMonitorCardLocal.svelte";
import { resolve } from '$app/paths';
import SystemMonitorCardLocal from '$lib/components/ti/SystemMonitorCardLocal.svelte';
</script>
<div class="container mx-auto px-4 py-6 max-w-7xl">
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="mb-8 flex items-center justify-between">
<div class="flex items-center gap-4">
<div
class="p-3 bg-linear-to-br from-primary/20 to-primary/10 rounded-2xl"
>
<div class="from-primary/20 to-primary/10 rounded-2xl bg-linear-to-br p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-primary"
class="text-primary h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -25,13 +24,13 @@
</svg>
</div>
<div>
<h1 class="text-4xl font-bold text-primary">Monitoramento SGSE</h1>
<h1 class="text-primary text-4xl font-bold">Monitoramento SGSE</h1>
<p class="text-base-content/60 mt-2 text-lg">
Sistema de monitoramento técnico em tempo real
</p>
</div>
</div>
<a href="/ti" class="btn btn-ghost">
<a href={resolve('/ti')} class="btn btn-ghost">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"

View File

@@ -1,17 +1,15 @@
import { v } from "convex/values";
import { mutation, query, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { Id, Doc } from "./_generated/dataModel";
import type { QueryCtx } from "./_generated/server";
import { v } from 'convex/values';
import { mutation, query, internalMutation } from './_generated/server';
import { internal } from './_generated/api';
import { Id } from './_generated/dataModel';
import type { QueryCtx } from './_generated/server';
/**
* Helper para obter usuário autenticado
*/
async function getUsuarioAutenticado(ctx: QueryCtx) {
const usuariosOnline = await ctx.db.query("usuarios").collect();
const usuarioOnline = usuariosOnline.find(
(u) => u.statusPresenca === "online"
);
const usuariosOnline = await ctx.db.query('usuarios').collect();
const usuarioOnline = usuariosOnline.find((u) => u.statusPresenca === 'online');
return usuarioOnline || null;
}
@@ -27,17 +25,17 @@ export const salvarMetricas = mutation({
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
errosCount: v.optional(v.number())
},
returns: v.object({
success: v.boolean(),
metricId: v.optional(v.id("systemMetrics")),
metricId: v.optional(v.id('systemMetrics'))
}),
handler: async (ctx, args) => {
const timestamp = Date.now();
// Salvar métricas
const metricId = await ctx.db.insert("systemMetrics", {
const metricId = await ctx.db.insert('systemMetrics', {
timestamp,
cpuUsage: args.cpuUsage,
memoryUsage: args.memoryUsage,
@@ -46,19 +44,19 @@ export const salvarMetricas = mutation({
usuariosOnline: args.usuariosOnline,
mensagensPorMinuto: args.mensagensPorMinuto,
tempoRespostaMedio: args.tempoRespostaMedio,
errosCount: args.errosCount,
errosCount: args.errosCount
});
// Verificar alertas após salvar métricas
await ctx.scheduler.runAfter(0, internal.monitoramento.verificarAlertasInternal, {
metricId,
metricId
});
// Limpar métricas antigas (mais de 30 dias)
const dataLimite = Date.now() - 30 * 24 * 60 * 60 * 1000;
const metricasAntigas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) => q.lt("timestamp", dataLimite))
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.lt('timestamp', dataLimite))
.collect();
for (const metrica of metricasAntigas) {
@@ -67,9 +65,9 @@ export const salvarMetricas = mutation({
return {
success: true,
metricId,
metricId
};
},
}
});
/**
@@ -77,31 +75,31 @@ export const salvarMetricas = mutation({
*/
export const configurarAlerta = mutation({
args: {
alertId: v.optional(v.id("alertConfigurations")),
alertId: v.optional(v.id('alertConfigurations')),
metricName: v.string(),
threshold: v.number(),
operator: v.union(
v.literal(">"),
v.literal("<"),
v.literal(">="),
v.literal("<="),
v.literal("==")
v.literal('>'),
v.literal('<'),
v.literal('>='),
v.literal('<='),
v.literal('==')
),
enabled: v.boolean(),
notifyByEmail: v.boolean(),
notifyByChat: v.boolean(),
notifyByChat: v.boolean()
},
returns: v.object({
success: v.boolean(),
alertId: v.id("alertConfigurations"),
alertId: v.id('alertConfigurations')
}),
handler: async (ctx, args) => {
const usuario = await getUsuarioAutenticado(ctx);
if (!usuario) {
throw new Error("Não autenticado");
throw new Error('Não autenticado');
}
let alertId: Id<"alertConfigurations">;
let alertId: Id<'alertConfigurations'>;
if (args.alertId) {
// Atualizar alerta existente
@@ -112,12 +110,12 @@ export const configurarAlerta = mutation({
enabled: args.enabled,
notifyByEmail: args.notifyByEmail,
notifyByChat: args.notifyByChat,
lastModified: Date.now(),
lastModified: Date.now()
});
alertId = args.alertId;
} else {
// Criar novo alerta
alertId = await ctx.db.insert("alertConfigurations", {
alertId = await ctx.db.insert('alertConfigurations', {
metricName: args.metricName,
threshold: args.threshold,
operator: args.operator,
@@ -125,15 +123,15 @@ export const configurarAlerta = mutation({
notifyByEmail: args.notifyByEmail,
notifyByChat: args.notifyByChat,
createdBy: usuario._id,
lastModified: Date.now(),
lastModified: Date.now()
});
}
return {
success: true,
alertId,
alertId
};
},
}
});
/**
@@ -143,27 +141,27 @@ export const listarAlertas = query({
args: {},
returns: v.array(
v.object({
_id: v.id("alertConfigurations"),
_id: v.id('alertConfigurations'),
metricName: v.string(),
threshold: v.number(),
operator: v.union(
v.literal(">"),
v.literal("<"),
v.literal(">="),
v.literal("<="),
v.literal("==")
v.literal('>'),
v.literal('<'),
v.literal('>='),
v.literal('<='),
v.literal('==')
),
enabled: v.boolean(),
notifyByEmail: v.boolean(),
notifyByChat: v.boolean(),
createdBy: v.id("usuarios"),
lastModified: v.number(),
createdBy: v.id('usuarios'),
lastModified: v.number()
})
),
handler: async (ctx) => {
const alertas = await ctx.db.query("alertConfigurations").collect();
const alertas = await ctx.db.query('alertConfigurations').collect();
return alertas;
},
}
});
/**
@@ -174,11 +172,11 @@ export const obterMetricas = query({
dataInicio: v.optional(v.number()),
dataFim: v.optional(v.number()),
metricName: v.optional(v.string()),
limit: v.optional(v.number()),
limit: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id("systemMetrics"),
_id: v.id('systemMetrics'),
timestamp: v.number(),
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
@@ -187,7 +185,7 @@ export const obterMetricas = query({
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
errosCount: v.optional(v.number())
})
),
handler: async (ctx, args) => {
@@ -197,28 +195,26 @@ export const obterMetricas = query({
const inicio: number = args.dataInicio as number;
const fim: number = args.dataFim as number;
metricas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) =>
q.gte("timestamp", inicio).lte("timestamp", fim)
)
.order("desc")
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', inicio).lte('timestamp', fim))
.order('desc')
.collect();
} else if (args.dataInicio !== undefined) {
const inicio: number = args.dataInicio as number;
metricas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) => q.gte("timestamp", inicio))
.order("desc")
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', inicio))
.order('desc')
.collect();
} else if (args.dataFim !== undefined) {
const fim: number = args.dataFim as number;
metricas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) => q.lte("timestamp", fim))
.order("desc")
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.lte('timestamp', fim))
.order('desc')
.collect();
} else {
metricas = await ctx.db.query("systemMetrics").order("desc").collect();
metricas = await ctx.db.query('systemMetrics').order('desc').collect();
}
// Limitar resultados
@@ -227,7 +223,7 @@ export const obterMetricas = query({
}
return metricas;
},
}
});
/**
@@ -237,7 +233,7 @@ export const obterMetricasRecentes = query({
args: {},
returns: v.array(
v.object({
_id: v.id("systemMetrics"),
_id: v.id('systemMetrics'),
timestamp: v.number(),
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
@@ -246,20 +242,20 @@ export const obterMetricasRecentes = query({
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
errosCount: v.optional(v.number())
})
),
handler: async (ctx) => {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
const metricas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) => q.gte("timestamp", umaHoraAtras))
.order("desc")
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.order('desc')
.take(100);
return metricas;
},
}
});
/**
@@ -269,7 +265,7 @@ export const obterUltimaMetrica = query({
args: {},
returns: v.union(
v.object({
_id: v.id("systemMetrics"),
_id: v.id('systemMetrics'),
timestamp: v.number(),
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
@@ -278,18 +274,15 @@ export const obterUltimaMetrica = query({
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
errosCount: v.optional(v.number())
}),
v.null()
),
handler: async (ctx) => {
const metrica = await ctx.db
.query("systemMetrics")
.order("desc")
.first();
const metrica = await ctx.db.query('systemMetrics').order('desc').first();
return metrica || null;
},
}
});
/**
@@ -297,7 +290,7 @@ export const obterUltimaMetrica = query({
*/
export const verificarAlertasInternal = internalMutation({
args: {
metricId: v.id("systemMetrics"),
metricId: v.id('systemMetrics')
},
returns: v.null(),
handler: async (ctx, args) => {
@@ -306,32 +299,32 @@ export const verificarAlertasInternal = internalMutation({
// Buscar configurações de alerta ativas
const alertasAtivos = await ctx.db
.query("alertConfigurations")
.withIndex("by_enabled", (q) => q.eq("enabled", true))
.query('alertConfigurations')
.withIndex('by_enabled', (q) => q.eq('enabled', true))
.collect();
for (const alerta of alertasAtivos) {
// Obter valor da métrica correspondente, validando tipo número
const rawValue = (metrica as Record<string, unknown>)[alerta.metricName];
if (typeof rawValue !== "number") continue;
if (typeof rawValue !== 'number') continue;
const metricValue = rawValue;
// Verificar se o alerta deve ser disparado
let shouldTrigger = false;
switch (alerta.operator) {
case ">":
case '>':
shouldTrigger = metricValue > alerta.threshold;
break;
case "<":
case '<':
shouldTrigger = metricValue < alerta.threshold;
break;
case ">=":
case '>=':
shouldTrigger = metricValue >= alerta.threshold;
break;
case "<=":
case '<=':
shouldTrigger = metricValue <= alerta.threshold;
break;
case "==":
case '==':
shouldTrigger = metricValue === alerta.threshold;
break;
}
@@ -340,51 +333,51 @@ export const verificarAlertasInternal = internalMutation({
// Verificar se já existe um alerta triggered recente (últimos 5 minutos)
const cincoMinutosAtras = Date.now() - 5 * 60 * 1000;
const alertaRecente = await ctx.db
.query("alertHistory")
.withIndex("by_config", (q) =>
q.eq("configId", alerta._id).gte("timestamp", cincoMinutosAtras)
.query('alertHistory')
.withIndex('by_config', (q) =>
q.eq('configId', alerta._id).gte('timestamp', cincoMinutosAtras)
)
.filter((q) => q.eq(q.field("status"), "triggered"))
.filter((q) => q.eq(q.field('status'), 'triggered'))
.first();
// Se já existe alerta recente, não disparar novamente
if (alertaRecente) continue;
// Registrar alerta no histórico
await ctx.db.insert("alertHistory", {
await ctx.db.insert('alertHistory', {
configId: alerta._id,
metricName: alerta.metricName,
metricValue,
threshold: alerta.threshold,
timestamp: Date.now(),
status: "triggered",
status: 'triggered',
notificationsSent: {
email: alerta.notifyByEmail,
chat: alerta.notifyByChat,
},
chat: alerta.notifyByChat
}
});
// Criar notificação no chat se configurado
if (alerta.notifyByChat) {
// Buscar roles administrativas (nível <= 1) e filtrar usuários por roleId
const rolesAdminOuTi = await ctx.db
.query("roles")
.filter((q) => q.lte(q.field("nivel"), 1))
.query('roles')
.filter((q) => q.lte(q.field('nivel'), 1))
.collect();
const rolesPermitidas = new Set(rolesAdminOuTi.map((r) => r._id));
const usuarios = await ctx.db.query("usuarios").collect();
const usuarios = await ctx.db.query('usuarios').collect();
const usuariosTI = usuarios.filter((u) => rolesPermitidas.has(u.roleId));
for (const usuario of usuariosTI) {
await ctx.db.insert("notificacoes", {
await ctx.db.insert('notificacoes', {
usuarioId: usuario._id,
tipo: "nova_mensagem",
tipo: 'nova_mensagem',
titulo: `⚠️ Alerta de Sistema: ${alerta.metricName}`,
descricao: `Métrica ${alerta.metricName} está em ${metricValue.toFixed(2)}% (limite: ${alerta.threshold}%)`,
lida: false,
criadaEm: Date.now(),
criadaEm: Date.now()
});
}
}
@@ -397,7 +390,7 @@ export const verificarAlertasInternal = internalMutation({
}
return null;
},
}
});
/**
@@ -407,16 +400,16 @@ export const gerarRelatorio = query({
args: {
dataInicio: v.number(),
dataFim: v.number(),
metricNames: v.optional(v.array(v.string())),
metricNames: v.optional(v.array(v.string()))
},
returns: v.object({
periodo: v.object({
inicio: v.number(),
fim: v.number(),
fim: v.number()
}),
metricas: v.array(
v.object({
_id: v.id("systemMetrics"),
_id: v.id('systemMetrics'),
timestamp: v.number(),
cpuUsage: v.optional(v.number()),
memoryUsage: v.optional(v.number()),
@@ -425,58 +418,74 @@ export const gerarRelatorio = query({
usuariosOnline: v.optional(v.number()),
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
errosCount: v.optional(v.number())
})
),
estatisticas: v.object({
cpuUsage: v.optional(v.object({
cpuUsage: v.optional(
v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
memoryUsage: v.optional(v.object({
avg: v.number()
})
),
memoryUsage: v.optional(
v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
networkLatency: v.optional(v.object({
avg: v.number()
})
),
networkLatency: v.optional(
v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
storageUsed: v.optional(v.object({
avg: v.number()
})
),
storageUsed: v.optional(
v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
usuariosOnline: v.optional(v.object({
avg: v.number()
})
),
usuariosOnline: v.optional(
v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
mensagensPorMinuto: v.optional(v.object({
avg: v.number()
})
),
mensagensPorMinuto: v.optional(
v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
tempoRespostaMedio: v.optional(v.object({
avg: v.number()
})
),
tempoRespostaMedio: v.optional(
v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
errosCount: v.optional(v.object({
avg: v.number()
})
),
errosCount: v.optional(
v.object({
min: v.number(),
max: v.number(),
avg: v.number(),
})),
}),
avg: v.number()
})
)
})
}),
handler: async (ctx, args) => {
// Buscar métricas no período
const metricas = await ctx.db
.query("systemMetrics")
.withIndex("by_timestamp", (q) =>
q.gte("timestamp", args.dataInicio).lte("timestamp", args.dataFim)
.query('systemMetrics')
.withIndex('by_timestamp', (q) =>
q.gte('timestamp', args.dataInicio).lte('timestamp', args.dataFim)
)
.collect();
@@ -488,7 +497,7 @@ export const gerarRelatorio = query({
return {
min: Math.min(...valores),
max: Math.max(...valores),
avg: valores.reduce((a, b) => a + b, 0) / valores.length,
avg: valores.reduce((a, b) => a + b, 0) / valores.length
};
};
@@ -516,18 +525,18 @@ export const gerarRelatorio = query({
),
errosCount: calcularEstatisticas(
metricas.map((m) => m.errosCount).filter((v) => v !== undefined) as number[]
),
)
};
return {
periodo: {
inicio: args.dataInicio,
fim: args.dataFim,
fim: args.dataFim
},
metricas,
estatisticas,
estatisticas
};
},
}
});
/**
@@ -535,15 +544,15 @@ export const gerarRelatorio = query({
*/
export const deletarAlerta = mutation({
args: {
alertId: v.id("alertConfigurations"),
alertId: v.id('alertConfigurations')
},
returns: v.object({
success: v.boolean(),
success: v.boolean()
}),
handler: async (ctx, args) => {
await ctx.db.delete(args.alertId);
return { success: true };
},
}
});
/**
@@ -551,31 +560,177 @@ export const deletarAlerta = mutation({
*/
export const obterHistoricoAlertas = query({
args: {
limit: v.optional(v.number()),
limit: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id("alertHistory"),
configId: v.id("alertConfigurations"),
_id: v.id('alertHistory'),
configId: v.id('alertConfigurations'),
metricName: v.string(),
metricValue: v.number(),
threshold: v.number(),
timestamp: v.number(),
status: v.union(v.literal("triggered"), v.literal("resolved")),
status: v.union(v.literal('triggered'), v.literal('resolved')),
notificationsSent: v.object({
email: v.boolean(),
chat: v.boolean(),
}),
chat: v.boolean()
})
})
),
handler: async (ctx, args) => {
const limit = args.limit || 50;
const historico = await ctx.db
.query("alertHistory")
.order("desc")
.take(limit);
const historico = await ctx.db.query('alertHistory').order('desc').take(limit);
return historico;
},
}
});
/**
* Status consolidado do sistema para o dashboard
*/
export const getStatusSistema = query({
args: {},
returns: v.object({
usuariosOnline: v.number(),
totalRegistros: v.number(),
tempoMedioResposta: v.number(),
cpuUsada: v.number(),
memoriaUsada: v.number(),
ultimaAtualizacao: v.number()
}),
handler: async (ctx) => {
// Última métrica, se existir
const ultimaMetrica = (await ctx.db.query('systemMetrics').order('desc').first()) ?? null;
// Usuários online: usar métrica se disponível, senão derivar de usuários
let usuariosOnline = 0;
if (ultimaMetrica?.usuariosOnline !== undefined) {
usuariosOnline = ultimaMetrica.usuariosOnline;
} else {
const usuarios = await ctx.db.query('usuarios').collect();
usuariosOnline = usuarios.filter((u) => u.statusPresenca === 'online').length;
}
// Total de registros (estimativa baseada em tabelas principais)
const [usuarios, funcionarios, simbolos, solicitacoesAcesso, alertas, metricas] =
await Promise.all([
ctx.db.query('usuarios').collect(),
ctx.db.query('funcionarios').collect(),
ctx.db.query('simbolos').collect(),
ctx.db.query('solicitacoesAcesso').collect(),
ctx.db.query('alertConfigurations').collect(),
ctx.db.query('systemMetrics').take(100) // não precisa contar tudo
]);
const totalRegistros =
usuarios.length +
funcionarios.length +
simbolos.length +
solicitacoesAcesso.length +
alertas.length +
metricas.length;
// Métricas de performance com fallbacks seguros
const tempoMedioResposta = ultimaMetrica?.tempoRespostaMedio ?? 0;
const cpuUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.cpuUsage ?? 0) * 100) / 100)
);
const memoriaUsada = Math.max(
0,
Math.min(100, Math.round((ultimaMetrica?.memoryUsage ?? 0) * 100) / 100)
);
const ultimaAtualizacao = ultimaMetrica?.timestamp ?? Date.now();
return {
usuariosOnline,
totalRegistros,
tempoMedioResposta,
cpuUsada,
memoriaUsada,
ultimaAtualizacao
};
}
});
/**
* Atividade do banco no último minuto (agregada em buckets)
* Usa mensagensPorMinuto como proxy de atividade quando disponível.
*/
export const getAtividadeBancoDados = query({
args: {},
returns: v.object({
historico: v.array(
v.object({
entradas: v.number(),
saidas: v.number()
})
)
}),
handler: async (ctx) => {
const agora = Date.now();
const haUmMinuto = agora - 60 * 1000;
const metricasRecentes = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', haUmMinuto))
.order('asc')
.collect();
// Bucketizar em 30 pontos (~2s cada) para visualização
const numBuckets = 30;
const bucketSizeMs = Math.ceil(60_000 / numBuckets);
const historico: Array<{ entradas: number; saidas: number }> = [];
for (let i = 0; i < numBuckets; i++) {
const inicio = haUmMinuto + i * bucketSizeMs;
const fim = inicio + bucketSizeMs;
const bucketMetricas = metricasRecentes.filter(
(m) => m.timestamp >= inicio && m.timestamp < fim
);
// Usar mensagensPorMinuto como proxy de "entradas"; "saídas" como fração
const somaMensagens =
bucketMetricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0) || 0;
const entradas = Math.max(0, Math.round(somaMensagens));
const saidas = Math.max(0, Math.round(entradas * 0.6));
historico.push({ entradas, saidas });
}
return { historico };
}
});
/**
* Distribuição de operações (estimada a partir das métricas)
*/
export const getDistribuicaoRequisicoes = query({
args: {},
returns: v.object({
queries: v.number(),
mutations: v.number(),
leituras: v.number(),
escritas: v.number()
}),
handler: async (ctx) => {
const umaHoraAtras = Date.now() - 60 * 60 * 1000;
const metricas = await ctx.db
.query('systemMetrics')
.withIndex('by_timestamp', (q) => q.gte('timestamp', umaHoraAtras))
.order('desc')
.take(100);
const totalOps = Math.max(
0,
Math.round(metricas.reduce((acc, m) => acc + (m.mensagensPorMinuto ?? 0), 0))
);
const queries = Math.round(totalOps * 0.7);
const mutations = Math.max(0, totalOps - queries);
const leituras = queries;
const escritas = mutations;
return { queries, mutations, leituras, escritas };
}
});