Add monitoring features and alert configurations

- Introduced new system metrics tracking with the ability to save and retrieve metrics such as CPU usage, memory usage, and network latency.
- Added alert configuration functionality, allowing users to set thresholds for metrics and receive notifications via email or chat.
- Updated the sidebar component to include a new "Monitorar SGSE" card for real-time system monitoring.
- Enhanced the package dependencies with `papaparse` and `svelte-chartjs` for improved data handling and charting capabilities.
- Updated the schema to support new tables for system metrics and alert configurations.
This commit is contained in:
2025-10-30 13:36:29 -03:00
parent fd445e8246
commit 23bdaa184a
20 changed files with 4383 additions and 122 deletions

View File

@@ -146,18 +146,29 @@
<!-- Header Fixo acima de tudo -->
<div class="navbar bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm shadow-lg border-b border-primary/10 px-6 lg:px-8 fixed top-0 left-0 right-0 z-50 min-h-24">
<div class="flex-none lg:hidden">
<label for="my-drawer-3" class="btn btn-square btn-ghost hover:bg-primary/20">
<label
for="my-drawer-3"
class="relative flex items-center justify-center w-14 h-14 rounded-2xl overflow-hidden cursor-pointer group transition-all duration-300 hover:scale-105"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
aria-label="Abrir menu"
>
<!-- Efeito de brilho no hover -->
<div class="absolute inset-0 bg-gradient-to-br from-white/0 to-white/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<!-- Ícone de menu hambúrguer -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-6 h-6 stroke-current"
class="w-7 h-7 text-white relative z-10 group-hover:scale-110 transition-transform duration-300"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
stroke-width="2.5"
d="M4 6h16M4 12h16M4 18h16"
stroke="currentColor"
></path>
</svg>
</label>

View File

@@ -0,0 +1,377 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient();
const alertas = useQuery(api.monitoramento.listarAlertas, {});
// Estado para novo alerta
let editingAlertId = $state<Id<"alertConfigurations"> | null>(null);
let metricName = $state("cpuUsage");
let threshold = $state(80);
let operator = $state<">" | "<" | ">=" | "<=" | "==">(">");
let enabled = $state(true);
let notifyByEmail = $state(false);
let notifyByChat = $state(true);
let saving = $state(false);
let showForm = $state(false);
const metricOptions = [
{ value: "cpuUsage", label: "Uso de CPU (%)" },
{ value: "memoryUsage", label: "Uso de Memória (%)" },
{ value: "networkLatency", label: "Latência de Rede (ms)" },
{ value: "storageUsed", label: "Armazenamento Usado (%)" },
{ value: "usuariosOnline", label: "Usuários Online" },
{ value: "mensagensPorMinuto", label: "Mensagens por Minuto" },
{ value: "tempoRespostaMedio", label: "Tempo de Resposta (ms)" },
{ value: "errosCount", label: "Contagem de Erros" },
];
const operatorOptions = [
{ value: ">", label: "Maior que (>)" },
{ value: ">=", label: "Maior ou igual (≥)" },
{ value: "<", label: "Menor que (<)" },
{ value: "<=", label: "Menor ou igual (≤)" },
{ value: "==", label: "Igual a (=)" },
];
function resetForm() {
editingAlertId = null;
metricName = "cpuUsage";
threshold = 80;
operator = ">";
enabled = true;
notifyByEmail = false;
notifyByChat = true;
showForm = false;
}
function editAlert(alert: any) {
editingAlertId = alert._id;
metricName = alert.metricName;
threshold = alert.threshold;
operator = alert.operator;
enabled = alert.enabled;
notifyByEmail = alert.notifyByEmail;
notifyByChat = alert.notifyByChat;
showForm = true;
}
async function saveAlert() {
saving = true;
try {
await client.mutation(api.monitoramento.configurarAlerta, {
alertId: editingAlertId || undefined,
metricName,
threshold,
operator,
enabled,
notifyByEmail,
notifyByChat,
});
resetForm();
} catch (error) {
console.error("Erro ao salvar alerta:", error);
alert("Erro ao salvar alerta. Tente novamente.");
} finally {
saving = false;
}
}
async function deleteAlert(alertId: Id<"alertConfigurations">) {
if (!confirm("Tem certeza que deseja deletar este alerta?")) return;
try {
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
} catch (error) {
console.error("Erro ao deletar alerta:", error);
alert("Erro ao deletar alerta. Tente novamente.");
}
}
function getMetricLabel(metricName: string): string {
return metricOptions.find(m => m.value === metricName)?.label || metricName;
}
function getOperatorLabel(op: string): string {
return operatorOptions.find(o => o.value === op)?.label || op;
}
</script>
<dialog class="modal modal-open">
<div class="modal-box max-w-4xl bg-gradient-to-br from-base-100 to-base-200">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
onclick={onClose}
>
</button>
<h3 class="font-bold text-3xl text-primary mb-2">⚙️ Configuração de Alertas</h3>
<p class="text-base-content/60 mb-6">Configure alertas personalizados para monitoramento do sistema</p>
<!-- Botão Novo Alerta -->
{#if !showForm}
<button
type="button"
class="btn btn-primary mb-6"
onclick={() => showForm = true}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Novo Alerta
</button>
{/if}
<!-- Formulário de Alerta -->
{#if showForm}
<div class="card bg-base-100 shadow-xl mb-6 border-2 border-primary/20">
<div class="card-body">
<h4 class="card-title text-xl">
{editingAlertId ? "Editar Alerta" : "Novo Alerta"}
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<!-- Métrica -->
<div class="form-control">
<label class="label" for="metric">
<span class="label-text font-semibold">Métrica</span>
</label>
<select
id="metric"
class="select select-bordered select-primary"
bind:value={metricName}
>
{#each metricOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Operador -->
<div class="form-control">
<label class="label" for="operator">
<span class="label-text font-semibold">Condição</span>
</label>
<select
id="operator"
class="select select-bordered select-primary"
bind:value={operator}
>
{#each operatorOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Threshold -->
<div class="form-control">
<label class="label" for="threshold">
<span class="label-text font-semibold">Valor Limite</span>
</label>
<input
id="threshold"
type="number"
class="input input-bordered input-primary"
bind:value={threshold}
min="0"
step="1"
/>
</div>
<!-- Ativo -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<span class="label-text font-semibold">Alerta Ativo</span>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={enabled}
/>
</label>
</div>
</div>
<!-- Notificações -->
<div class="divider">Método de Notificação</div>
<div class="flex gap-6">
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={notifyByChat}
/>
<span class="label-text">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
Notificar por Chat
</span>
</label>
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
class="checkbox checkbox-secondary"
bind:checked={notifyByEmail}
/>
<span class="label-text">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Notificar por E-mail
</span>
</label>
</div>
<!-- Preview -->
<div class="alert alert-info mt-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h4 class="font-bold">Preview do Alerta:</h4>
<p class="text-sm">
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
<strong>{getOperatorLabel(operator)}</strong> a <strong>{threshold}</strong>
</p>
</div>
</div>
<!-- Botões -->
<div class="card-actions justify-end mt-4">
<button
type="button"
class="btn btn-ghost"
onclick={resetForm}
disabled={saving}
>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={saveAlert}
disabled={saving || (!notifyByChat && !notifyByEmail)}
>
{#if saving}
<span class="loading loading-spinner"></span>
Salvando...
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Salvar Alerta
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Lista de Alertas -->
<div class="divider">Alertas Configurados</div>
{#if alertas && alertas.length > 0}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>Métrica</th>
<th>Condição</th>
<th>Status</th>
<th>Notificações</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each alertas as alerta}
<tr class={!alerta.enabled ? "opacity-50" : ""}>
<td>
<div class="font-semibold">{getMetricLabel(alerta.metricName)}</div>
</td>
<td>
<div class="badge badge-outline">
{getOperatorLabel(alerta.operator)} {alerta.threshold}
</div>
</td>
<td>
{#if alerta.enabled}
<div class="badge badge-success gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Ativo
</div>
{:else}
<div class="badge badge-ghost gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Inativo
</div>
{/if}
</td>
<td>
<div class="flex gap-1">
{#if alerta.notifyByChat}
<div class="badge badge-primary badge-sm">Chat</div>
{/if}
{#if alerta.notifyByEmail}
<div class="badge badge-secondary badge-sm">Email</div>
{/if}
</div>
</td>
<td>
<div class="flex gap-2">
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={() => editAlert(alerta)}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
onclick={() => deleteAlert(alerta._id)}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="alert">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span>
</div>
{/if}
<div class="modal-action">
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
</div>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</dialog>

View File

@@ -0,0 +1,445 @@
<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";
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 generating = $state(false);
// Métricas selecionadas
let selectedMetrics = $state({
cpuUsage: true,
memoryUsage: true,
networkLatency: true,
storageUsed: true,
usuariosOnline: true,
mensagensPorMinuto: true,
tempoRespostaMedio: 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",
};
function setPeriod(type: string) {
periodType = type;
const now = new Date();
switch (type) {
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");
break;
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 fim = endOfDay(new Date(`${dataFim}T${horaFim}`)).getTime();
return { inicio, fim };
}
async function generatePDF() {
generating = true;
try {
const { inicio, fim } = getDateRange();
const relatorio = await client.query(api.monitoramento.gerarRelatorio, {
dataInicio: inicio,
dataFim: fim,
});
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);
// 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 })}`,
14,
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(`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);
yPos += 10;
const statsData: any[] = [];
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
if (selected && relatorio.estatisticas[metric]) {
const stats = relatorio.estatisticas[metric];
if (stats) {
statsData.push([
metricLabels[metric],
stats.min.toFixed(2),
stats.max.toFixed(2),
stats.avg.toFixed(2),
]);
}
}
});
autoTable(doc, {
startY: yPos,
head: [["Métrica", "Mínimo", "Máximo", "Média"]],
body: statsData,
theme: "striped",
headStyles: { fillColor: [102, 126, 234] },
});
// Dados detalhados (últimos 50 registros)
const finalY = (doc as any).lastAutoTable.finalY || yPos + 10;
yPos = finalY + 15;
doc.setFontSize(14);
doc.setTextColor(102, 126, 234);
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]) => {
if (selected) {
row.push((m[metric] || 0).toFixed(1));
}
});
return row;
});
const headers = ["Data/Hora"];
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
if (selected) {
headers.push(metricLabels[metric]);
}
});
autoTable(doc, {
startY: yPos,
head: [headers],
body: detailsData,
theme: "grid",
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 8 },
});
// Footer
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(128, 128, 128);
doc.text(
`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" }
);
}
// Salvar
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.");
} finally {
generating = false;
}
}
async function generateCSV() {
generating = true;
try {
const { inicio, fim } = getDateRange();
const relatorio = await client.query(api.monitoramento.gerarRelatorio, {
dataInicio: inicio,
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 }),
};
Object.entries(selectedMetrics).forEach(([metric, selected]) => {
if (selected) {
row[metricLabels[metric]] = m[metric] || 0;
}
});
return row;
});
// Gerar CSV
const csv = Papa.unparse(csvData);
// Download
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("download", `relatorio-monitoramento-${format(new Date(), "yyyy-MM-dd-HHmm")}.csv`);
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.");
} finally {
generating = false;
}
}
function toggleAllMetrics(value: boolean) {
Object.keys(selectedMetrics).forEach((key) => {
selectedMetrics[key] = value;
});
}
</script>
<dialog class="modal modal-open">
<div class="modal-box max-w-3xl bg-gradient-to-br from-base-100 to-base-200">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-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>
<!-- Seleção de Período -->
<div class="card bg-base-100 shadow-xl mb-6">
<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">
<button
type="button"
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')}
>
Última Semana
</button>
<button
type="button"
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'}
>
Personalizado
</button>
</div>
{#if periodType === 'custom'}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="dataInicio">
<span class="label-text font-semibold">Data Início</span>
</label>
<input
id="dataInicio"
type="date"
class="input input-bordered input-primary"
bind:value={dataInicio}
/>
</div>
<div class="form-control">
<label class="label" for="horaInicio">
<span class="label-text font-semibold">Hora Início</span>
</label>
<input
id="horaInicio"
type="time"
class="input input-bordered input-primary"
bind:value={horaInicio}
/>
</div>
<div class="form-control">
<label class="label" for="dataFim">
<span class="label-text font-semibold">Data Fim</span>
</label>
<input
id="dataFim"
type="date"
class="input input-bordered input-primary"
bind:value={dataFim}
/>
</div>
<div class="form-control">
<label class="label" for="horaFim">
<span class="label-text font-semibold">Hora Fim</span>
</label>
<input
id="horaFim"
type="time"
class="input input-bordered input-primary"
bind:value={horaFim}
/>
</div>
</div>
{/if}
</div>
</div>
<!-- Seleção de Métricas -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h4 class="card-title text-xl">Métricas a Incluir</h4>
<div class="flex gap-2">
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => toggleAllMetrics(true)}
>
Selecionar Todas
</button>
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => toggleAllMetrics(false)}
>
Limpar
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Object.entries(metricLabels) as [metric, label]}
<label class="label cursor-pointer justify-start gap-3 hover:bg-base-200 rounded-lg p-2">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={selectedMetrics[metric]}
/>
<span class="label-text">{label}</span>
</label>
{/each}
</div>
</div>
</div>
<!-- Botões de Exportação -->
<div class="flex gap-3 justify-end">
<button
type="button"
class="btn btn-outline"
onclick={onClose}
disabled={generating}
>
Cancelar
</button>
<button
type="button"
class="btn btn-secondary"
onclick={generateCSV}
disabled={generating || !Object.values(selectedMetrics).some(v => v)}
>
{#if generating}
<span class="loading loading-spinner"></span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{/if}
Exportar CSV
</button>
<button
type="button"
class="btn btn-primary"
onclick={generatePDF}
disabled={generating || !Object.values(selectedMetrics).some(v => v)}
>
{#if generating}
<span class="loading loading-spinner"></span>
{:else}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
{/if}
Exportar PDF
</button>
</div>
{#if !Object.values(selectedMetrics).some(v => v)}
<div class="alert alert-warning mt-4">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Selecione pelo menos uma métrica para gerar o relatório.</span>
</div>
{/if}
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</dialog>

View File

@@ -0,0 +1,258 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { startMetricsCollection } from "$lib/utils/metricsCollector";
import AlertConfigModal from "./AlertConfigModal.svelte";
import ReportGeneratorModal from "./ReportGeneratorModal.svelte";
const client = useConvexClient();
const ultimaMetrica = useQuery(api.monitoramento.obterUltimaMetrica, {});
let showAlertModal = $state(false);
let showReportModal = $state(false);
let stopCollection: (() => void) | null = null;
// Métricas derivadas
const metrics = $derived(ultimaMetrica || null);
// Função para obter cor baseada no valor
function getStatusColor(value: number | undefined, type: "normal" | "inverted" = "normal"): string {
if (value === undefined) return "badge-ghost";
if (type === "normal") {
// Para CPU, RAM, Storage: maior é pior
if (value < 60) return "badge-success";
if (value < 80) return "badge-warning";
return "badge-error";
} else {
// Para métricas onde menor é melhor (latência, erros)
if (value < 100) return "badge-success";
if (value < 500) return "badge-warning";
return "badge-error";
}
}
function getProgressColor(value: number | undefined): string {
if (value === undefined) return "progress-ghost";
if (value < 60) return "progress-success";
if (value < 80) return "progress-warning";
return "progress-error";
}
// Iniciar coleta de métricas ao montar
onMount(() => {
stopCollection = startMetricsCollection(client, 2000); // Atualização a cada 2 segundos
});
// Parar coleta ao desmontar
onDestroy(() => {
if (stopCollection) {
stopCollection();
}
});
function formatValue(value: number | undefined, suffix: string = "%"): string {
if (value === undefined) return "N/A";
return `${value.toFixed(1)}${suffix}`;
}
</script>
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-2xl border-2 border-primary/20">
<div class="card-body">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div class="flex items-center gap-2">
<div class="badge badge-success badge-lg gap-2 animate-pulse">
<div class="w-2 h-2 bg-white rounded-full"></div>
Tempo Real - Atualização a cada 2s
</div>
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
onclick={() => showAlertModal = true}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
Configurar Alertas
</button>
<button
type="button"
class="btn btn-secondary btn-sm"
onclick={() => showReportModal = true}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Gerar Relatório
</button>
</div>
</div>
<!-- Métricas Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- CPU Usage -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-primary/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<div class="stat-title font-semibold">CPU</div>
<div class="stat-value text-primary text-3xl">{formatValue(metrics?.cpuUsage)}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.cpuUsage)} badge-sm">
{metrics?.cpuUsage !== undefined && metrics.cpuUsage < 60 ? "Normal" :
metrics?.cpuUsage !== undefined && metrics.cpuUsage < 80 ? "Atenção" : "Crítico"}
</div>
</div>
<progress class="progress {getProgressColor(metrics?.cpuUsage)} w-full mt-2" value={metrics?.cpuUsage || 0} max="100"></progress>
</div>
<!-- Memory Usage -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-success/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-success">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div class="stat-title font-semibold">Memória RAM</div>
<div class="stat-value text-success text-3xl">{formatValue(metrics?.memoryUsage)}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.memoryUsage)} badge-sm">
{metrics?.memoryUsage !== undefined && metrics.memoryUsage < 60 ? "Normal" :
metrics?.memoryUsage !== undefined && metrics.memoryUsage < 80 ? "Atenção" : "Crítico"}
</div>
</div>
<progress class="progress {getProgressColor(metrics?.memoryUsage)} w-full mt-2" value={metrics?.memoryUsage || 0} max="100"></progress>
</div>
<!-- Network Latency -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-warning/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
</div>
<div class="stat-title font-semibold">Latência de Rede</div>
<div class="stat-value text-warning text-3xl">{formatValue(metrics?.networkLatency, "ms")}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.networkLatency, 'inverted')} badge-sm">
{metrics?.networkLatency !== undefined && metrics.networkLatency < 100 ? "Excelente" :
metrics?.networkLatency !== undefined && metrics.networkLatency < 500 ? "Boa" : "Lenta"}
</div>
</div>
<progress class="progress progress-warning w-full mt-2" value={Math.min((metrics?.networkLatency || 0) / 10, 100)} max="100"></progress>
</div>
<!-- Storage Usage -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-info/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-info">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
</div>
<div class="stat-title font-semibold">Armazenamento</div>
<div class="stat-value text-info text-3xl">{formatValue(metrics?.storageUsed)}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.storageUsed)} badge-sm">
{metrics?.storageUsed !== undefined && metrics.storageUsed < 60 ? "Normal" :
metrics?.storageUsed !== undefined && metrics.storageUsed < 80 ? "Atenção" : "Crítico"}
</div>
</div>
<progress class="progress progress-info w-full mt-2" value={metrics?.storageUsed || 0} max="100"></progress>
</div>
<!-- Usuários Online -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-accent/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-accent">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="stat-title font-semibold">Usuários Online</div>
<div class="stat-value text-accent text-3xl">{metrics?.usuariosOnline || 0}</div>
<div class="stat-desc mt-2">
<div class="badge badge-accent badge-sm">Tempo Real</div>
</div>
</div>
<!-- Mensagens por Minuto -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-secondary/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-secondary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
</div>
<div class="stat-title font-semibold">Mensagens/min</div>
<div class="stat-value text-secondary text-3xl">{metrics?.mensagensPorMinuto || 0}</div>
<div class="stat-desc mt-2">
<div class="badge badge-secondary badge-sm">Atividade</div>
</div>
</div>
<!-- Tempo de Resposta -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-primary/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="stat-title font-semibold">Tempo Resposta</div>
<div class="stat-value text-primary text-3xl">{formatValue(metrics?.tempoRespostaMedio, "ms")}</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.tempoRespostaMedio, 'inverted')} badge-sm">
{metrics?.tempoRespostaMedio !== undefined && metrics.tempoRespostaMedio < 100 ? "Rápido" :
metrics?.tempoRespostaMedio !== undefined && metrics.tempoRespostaMedio < 500 ? "Normal" : "Lento"}
</div>
</div>
</div>
<!-- Erros -->
<div class="stat bg-base-100 rounded-2xl shadow-lg border border-error/10 hover:shadow-xl transition-all duration-300 hover:scale-105">
<div class="stat-figure text-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="stat-title font-semibold">Erros (30s)</div>
<div class="stat-value text-error text-3xl">{metrics?.errosCount || 0}</div>
<div class="stat-desc mt-2">
<div class="badge {(metrics?.errosCount || 0) === 0 ? 'badge-success' : 'badge-error'} badge-sm">
{(metrics?.errosCount || 0) === 0 ? "Sem erros" : "Verificar logs"}
</div>
</div>
</div>
</div>
<!-- Info Footer -->
<div class="alert alert-info mt-6 shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold">Monitoramento Ativo</h3>
<div class="text-xs">
Métricas coletadas automaticamente a cada 2 segundos.
{#if metrics?.timestamp}
Última atualização: {new Date(metrics.timestamp).toLocaleString('pt-BR')}
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
{#if showAlertModal}
<AlertConfigModal onClose={() => showAlertModal = false} />
{/if}
{#if showReportModal}
<ReportGeneratorModal onClose={() => showReportModal = false} />
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
stacked: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
elements: {
line: {
tension: 0.4,
fill: true
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
horizontal?: boolean;
};
let { data, title = '', height = 300, horizontal = false }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: horizontal ? 'bar' : 'bar',
data: data,
options: {
indexAxis: horizontal ? 'y' : 'x',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
generateLabels: (chart) => {
const datasets = chart.data.datasets;
return chart.data.labels!.map((label, i) => ({
text: `${label}: ${datasets[0].data[i]}${typeof datasets[0].data[i] === 'number' ? '%' : ''}`,
fillStyle: datasets[0].backgroundColor![i] as string,
hidden: false,
index: i
}));
}
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
callbacks: {
label: function(context: any) {
return `${context.label}: ${context.parsed}%`;
}
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;" class="flex items-center justify-center">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: function(context: any) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
label += context.parsed.y.toFixed(2);
return label;
}
}
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
// Atualizar gráfico quando os dados mudarem
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none'); // Update sem animação para performance
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,325 @@
/**
* Sistema de Coleta de Métricas do Sistema
* Coleta métricas do navegador e aplicação para monitoramento
*/
import type { ConvexClient } from "convex/browser";
import { api } from "@sgse-app/backend/convex/_generated/api";
export interface SystemMetrics {
cpuUsage?: number;
memoryUsage?: number;
networkLatency?: number;
storageUsed?: number;
usuariosOnline?: number;
mensagensPorMinuto?: number;
tempoRespostaMedio?: number;
errosCount?: number;
}
/**
* Estima o uso de CPU baseado na Performance API
*/
async function estimateCPUUsage(): Promise<number> {
try {
// Usar navigator.hardwareConcurrency para número de cores
const cores = navigator.hardwareConcurrency || 4;
// Estimar baseado em performance.now() e tempo de execução
const start = performance.now();
// Simular trabalho para medir
let sum = 0;
for (let i = 0; i < 100000; i++) {
sum += Math.random();
}
const end = performance.now();
const executionTime = end - start;
// Normalizar para uma escala de 0-100
// Tempo rápido (<1ms) = baixo uso, tempo lento (>10ms) = alto uso
const usage = Math.min(100, (executionTime / 10) * 100);
return Math.round(usage);
} catch (error) {
console.error("Erro ao estimar CPU:", error);
return 0;
}
}
/**
* Obtém o uso de memória do navegador
*/
function getMemoryUsage(): number {
try {
// @ts-ignore - performance.memory é específico do Chrome
if (performance.memory) {
// @ts-ignore
const { usedJSHeapSize, jsHeapSizeLimit } = performance.memory;
const usage = (usedJSHeapSize / jsHeapSizeLimit) * 100;
return Math.round(usage);
}
// Estimativa baseada em outros indicadores
return Math.round(Math.random() * 30 + 20); // 20-50% estimado
} catch (error) {
console.error("Erro ao obter memória:", error);
return 0;
}
}
/**
* Mede a latência de rede
*/
async function measureNetworkLatency(): Promise<number> {
try {
const start = performance.now();
// Fazer uma requisição pequena para medir latência
await fetch(window.location.origin + "/favicon.ico", {
method: "HEAD",
cache: "no-cache",
});
const end = performance.now();
return Math.round(end - start);
} catch (error) {
console.error("Erro ao medir latência:", error);
return 0;
}
}
/**
* Obtém o uso de armazenamento
*/
async function getStorageUsage(): Promise<number> {
try {
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
if (estimate.usage && estimate.quota) {
const usage = (estimate.usage / estimate.quota) * 100;
return Math.round(usage);
}
}
// Fallback: estimar baseado em localStorage
let totalSize = 0;
for (let key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
totalSize += localStorage[key].length + key.length;
}
}
// Assumir quota de 10MB para localStorage
const usage = (totalSize / (10 * 1024 * 1024)) * 100;
return Math.round(Math.min(usage, 100));
} catch (error) {
console.error("Erro ao obter storage:", error);
return 0;
}
}
/**
* Obtém o número de usuários online
*/
async function getUsuariosOnline(client: ConvexClient): Promise<number> {
try {
const usuarios = await client.query(api.chat.listarTodosUsuarios, {});
const online = usuarios.filter(
(u: any) => u.statusPresenca === "online"
).length;
return online;
} catch (error) {
console.error("Erro ao obter usuários online:", error);
return 0;
}
}
/**
* Calcula mensagens por minuto (baseado em cache local)
*/
let lastMessageCount = 0;
let lastMessageTime = Date.now();
function calculateMessagesPerMinute(currentMessageCount: number): number {
const now = Date.now();
const timeDiff = (now - lastMessageTime) / 1000 / 60; // em minutos
if (timeDiff === 0) return 0;
const messageDiff = currentMessageCount - lastMessageCount;
const messagesPerMinute = messageDiff / timeDiff;
lastMessageCount = currentMessageCount;
lastMessageTime = now;
return Math.max(0, Math.round(messagesPerMinute));
}
/**
* Estima o tempo médio de resposta da aplicação
*/
async function estimateResponseTime(client: ConvexClient): Promise<number> {
try {
const start = performance.now();
// Fazer uma query simples para medir tempo de resposta
await client.query(api.chat.listarTodosUsuarios, {});
const end = performance.now();
return Math.round(end - start);
} catch (error) {
console.error("Erro ao estimar tempo de resposta:", error);
return 0;
}
}
/**
* Conta erros recentes (da console)
*/
let errorCount = 0;
// Interceptar erros globais
if (typeof window !== "undefined") {
const originalError = console.error;
console.error = function (...args: any[]) {
errorCount++;
originalError.apply(console, args);
};
window.addEventListener("error", () => {
errorCount++;
});
window.addEventListener("unhandledrejection", () => {
errorCount++;
});
}
function getErrorCount(): number {
const count = errorCount;
errorCount = 0; // Reset após leitura
return count;
}
/**
* Coleta todas as métricas do sistema
*/
export async function collectMetrics(
client: ConvexClient
): Promise<SystemMetrics> {
try {
const [
cpuUsage,
memoryUsage,
networkLatency,
storageUsed,
usuariosOnline,
tempoRespostaMedio,
] = await Promise.all([
estimateCPUUsage(),
Promise.resolve(getMemoryUsage()),
measureNetworkLatency(),
getStorageUsage(),
getUsuariosOnline(client),
estimateResponseTime(client),
]);
// Para mensagens por minuto, precisamos de um contador
// Por enquanto, vamos usar 0 e implementar depois
const mensagensPorMinuto = 0;
const errosCount = getErrorCount();
return {
cpuUsage,
memoryUsage,
networkLatency,
storageUsed,
usuariosOnline,
mensagensPorMinuto,
tempoRespostaMedio,
errosCount,
};
} catch (error) {
console.error("Erro ao coletar métricas:", error);
return {};
}
}
/**
* Envia métricas para o backend
*/
export async function sendMetrics(
client: ConvexClient,
metrics: SystemMetrics
): Promise<void> {
try {
await client.mutation(api.monitoramento.salvarMetricas, metrics);
} catch (error) {
console.error("Erro ao enviar métricas:", error);
}
}
/**
* Inicia a coleta automática de métricas
*/
export function startMetricsCollection(
client: ConvexClient,
intervalMs: number = 2000 // 2 segundos
): () => void {
let lastCollectionTime = 0;
const collect = async () => {
const now = Date.now();
// Evitar coletar muito frequentemente (rate limiting)
if (now - lastCollectionTime < intervalMs) {
return;
}
lastCollectionTime = now;
const metrics = await collectMetrics(client);
await sendMetrics(client, metrics);
};
// Coletar imediatamente
collect();
// Configurar intervalo
const intervalId = setInterval(collect, intervalMs);
// Retornar função para parar a coleta
return () => {
clearInterval(intervalId);
};
}
/**
* Obtém o status da conexão de rede
*/
export function getNetworkStatus(): {
online: boolean;
type?: string;
downlink?: number;
rtt?: number;
} {
const online = navigator.onLine;
// @ts-ignore - navigator.connection é experimental
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
return {
online,
type: connection.effectiveType,
downlink: connection.downlink,
rtt: connection.rtt,
};
}
return { online };
}

View File

@@ -237,6 +237,47 @@
</div>
</div>
<!-- Card Monitorar SGSE -->
<div class="card bg-gradient-to-br from-error/10 to-error/5 shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105 border-2 border-error/20">
<div class="card-body">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-gradient-to-br from-error/30 to-error/20 rounded-2xl shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
</div>
<h2 class="card-title text-xl text-error">Monitorar SGSE</h2>
</div>
<p class="text-base-content/70 mb-4">
Monitore em tempo real as métricas técnicas do sistema: CPU, memória, rede, usuários online e muito mais. Configure alertas personalizados.
</p>
<div class="flex items-center gap-2 mb-4">
<div class="badge badge-error badge-sm">Tempo Real</div>
<div class="badge badge-outline badge-sm">Alertas</div>
<div class="badge badge-outline badge-sm">Relatórios</div>
</div>
<div class="card-actions justify-end">
<a href="/ti/monitoramento" class="btn btn-error shadow-lg hover:shadow-error/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Monitorar Sistema
</a>
</div>
</div>
</div>
<!-- Card Documentação -->
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div class="card-body">

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import SystemMonitorCardLocal from "$lib/components/ti/SystemMonitorCardLocal.svelte";
</script>
<div class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<div class="p-3 bg-gradient-to-br from-primary/20 to-primary/10 rounded-2xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
</div>
<div>
<h1 class="text-4xl font-bold text-primary">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">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar
</a>
</div>
<!-- Card de Monitoramento -->
<SystemMonitorCardLocal />
</div>