feat: Implementação completa do NoIdle - Cliente, Backend e Scripts
- Cliente Windows com modo silencioso e auto-start robusto - Backend Node.js + API REST - Frontend Next.js + Dashboard - Scripts PowerShell de configuração e diagnóstico - Documentação completa - Build scripts para Windows e Linux - Solução de auto-start após reinicialização Resolução do problema: Cliente não voltava ativo após reboot Solução: Registro do Windows + Task Scheduler + Modo silencioso
This commit is contained in:
413
backend/routes/activity.js
Normal file
413
backend/routes/activity.js
Normal file
@@ -0,0 +1,413 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { query } = require('../config/database');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const PDFDocument = require('pdfkit');
|
||||
|
||||
// LOG DE ATIVIDADE (com URLs opcionais)
|
||||
router.post('/log', async (req, res) => {
|
||||
try {
|
||||
const { device_id, window_title, application_name, idle_time_seconds, urls } = req.body;
|
||||
|
||||
console.log(`📥 POST /api/activity/log - Device: ${device_id}`);
|
||||
console.log(`📥 Body:`, { device_id, window_title, application_name, idle_time_seconds, urls_count: urls?.length || 0 });
|
||||
|
||||
if (!device_id) {
|
||||
console.log('❌ device_id não fornecido');
|
||||
return res.status(400).json({ error: 'device_id é obrigatório' });
|
||||
}
|
||||
|
||||
// Buscar device
|
||||
const deviceResult = await query(
|
||||
'SELECT id, company_id, device_name FROM devices WHERE device_id = $1',
|
||||
[device_id]
|
||||
);
|
||||
|
||||
if (deviceResult.rows.length === 0) {
|
||||
console.log(`❌ Dispositivo ${device_id} não encontrado`);
|
||||
return res.status(404).json({ error: 'Dispositivo não encontrado' });
|
||||
}
|
||||
|
||||
console.log(`✅ Dispositivo encontrado: ${deviceResult.rows[0].device_name} (ID: ${deviceResult.rows[0].id})`);
|
||||
|
||||
const device = deviceResult.rows[0];
|
||||
|
||||
// Inserir atividade
|
||||
await query(
|
||||
`INSERT INTO activities (device_id, company_id, window_title, application_name, idle_time_seconds, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())`,
|
||||
[device.id, device.company_id, window_title, application_name, idle_time_seconds || 0]
|
||||
);
|
||||
|
||||
// Atualizar last_seen e is_active quando receber atividade (como heartbeat alternativo)
|
||||
await query(
|
||||
'UPDATE devices SET last_seen = NOW(), is_active = true WHERE device_id = $1',
|
||||
[device_id]
|
||||
);
|
||||
|
||||
// Validar se está recebendo dados reais (não apenas "System Idle")
|
||||
if (window_title === 'System Idle' || window_title === '[IDLE]' || !window_title || window_title.trim() === '') {
|
||||
console.log(`⚠️ ATENÇÃO: Recebendo atividade com window_title inválido: "${window_title}"`);
|
||||
console.log(`⚠️ O cliente deve capturar o título real da janela ativa!`);
|
||||
}
|
||||
if (application_name === '[IDLE]' || !application_name || application_name.trim() === '') {
|
||||
console.log(`⚠️ ATENÇÃO: Recebendo atividade com application_name inválido: "${application_name}"`);
|
||||
console.log(`⚠️ O cliente deve capturar o executável real do processo ativo!`);
|
||||
}
|
||||
|
||||
console.log(`✅ Atividade registrada: ${application_name} - ${window_title}`);
|
||||
|
||||
// Se tem URLs, salvar também
|
||||
if (urls && Array.isArray(urls) && urls.length > 0) {
|
||||
let urlsSaved = 0;
|
||||
for (const urlData of urls) {
|
||||
try {
|
||||
if (urlData.url && urlData.url.trim() !== '') {
|
||||
await query(
|
||||
`INSERT INTO browsing_history (device_id, url, title, browser, visited_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())`,
|
||||
[device_id, urlData.url, urlData.title || '', urlData.browser || 'Chrome']
|
||||
);
|
||||
urlsSaved++;
|
||||
}
|
||||
} catch (urlError) {
|
||||
console.error(`❌ Erro ao salvar URL ${urlData.url}:`, urlError.message);
|
||||
// Continua tentando salvar outras URLs mesmo se uma falhar
|
||||
}
|
||||
}
|
||||
if (urlsSaved > 0) {
|
||||
console.log(`📊 ${urlsSaved} URLs registradas para ${device_id}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Atividade registrada' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao registrar atividade:', error);
|
||||
res.status(500).json({ error: 'Erro ao registrar atividade' });
|
||||
}
|
||||
});
|
||||
|
||||
// LOG DE SESSÃO (logon/logoff)
|
||||
router.post('/session', async (req, res) => {
|
||||
try {
|
||||
const { device_id, event_type, username } = req.body;
|
||||
|
||||
if (!device_id || !event_type) {
|
||||
return res.status(400).json({ error: 'device_id e event_type são obrigatórios' });
|
||||
}
|
||||
|
||||
if (!['logon', 'logoff'].includes(event_type)) {
|
||||
return res.status(400).json({ error: 'event_type deve ser logon ou logoff' });
|
||||
}
|
||||
|
||||
// Verificar se device existe
|
||||
const deviceCheck = await query(
|
||||
'SELECT device_id FROM devices WHERE device_id = $1',
|
||||
[device_id]
|
||||
);
|
||||
|
||||
if (deviceCheck.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Dispositivo não encontrado' });
|
||||
}
|
||||
|
||||
// Registrar evento
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO session_events (device_id, event_type, username, event_time)
|
||||
VALUES ($1, $2, $3, NOW())`,
|
||||
[device_id, event_type, username]
|
||||
);
|
||||
|
||||
console.log(`🔐 Evento de sessão: ${event_type} - ${device_id} (${username || 'N/A'})`);
|
||||
|
||||
res.json({ success: true, message: `Evento ${event_type} registrado` });
|
||||
} catch (dbError) {
|
||||
console.error(`❌ Erro ao registrar evento de sessão:`, dbError.message);
|
||||
if (dbError.code === '42501') {
|
||||
console.error(`⚠️ ERRO DE PERMISSÃO: A tabela session_events existe mas o usuário do banco não tem permissão.`);
|
||||
console.error(`⚠️ Execute no PostgreSQL: GRANT ALL ON session_events TO [usuario_do_banco];`);
|
||||
}
|
||||
// Retorna sucesso mesmo com erro de banco para não quebrar o cliente
|
||||
res.json({ success: true, message: `Evento ${event_type} recebido (erro ao salvar no banco)` });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao registrar sessão:', error);
|
||||
res.status(500).json({ error: 'Erro ao registrar sessão' });
|
||||
}
|
||||
});
|
||||
|
||||
// LISTAR ATIVIDADES
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT a.*, d.device_name, d.hostname
|
||||
FROM activities a
|
||||
JOIN devices d ON a.device_id = d.id
|
||||
WHERE a.company_id = $1
|
||||
ORDER BY a.timestamp DESC
|
||||
LIMIT 100`,
|
||||
[req.user.company_id]
|
||||
);
|
||||
|
||||
res.json({ success: true, activities: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Erro ao listar atividades:', error);
|
||||
res.status(500).json({ error: 'Erro ao listar atividades' });
|
||||
}
|
||||
});
|
||||
|
||||
// LISTAR HISTÓRICO DE NAVEGAÇÃO
|
||||
router.get('/browsing', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT bh.*, d.device_name, d.hostname
|
||||
FROM browsing_history bh
|
||||
JOIN devices d ON bh.device_id = d.device_id
|
||||
WHERE d.company_id = $1
|
||||
ORDER BY bh.visited_at DESC
|
||||
LIMIT 200`,
|
||||
[req.user.company_id]
|
||||
);
|
||||
|
||||
res.json({ success: true, history: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Erro ao listar histórico:', error);
|
||||
res.status(500).json({ error: 'Erro ao listar histórico' });
|
||||
}
|
||||
});
|
||||
|
||||
// LISTAR EVENTOS DE SESSÃO
|
||||
router.get('/sessions', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT se.*, d.device_name, d.hostname
|
||||
FROM session_events se
|
||||
JOIN devices d ON se.device_id = d.device_id
|
||||
WHERE d.company_id = $1
|
||||
ORDER BY se.event_time DESC
|
||||
LIMIT 100`,
|
||||
[req.user.company_id]
|
||||
);
|
||||
|
||||
res.json({ success: true, sessions: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Erro ao listar sessões:', error);
|
||||
res.status(500).json({ error: 'Erro ao listar sessões' });
|
||||
}
|
||||
});
|
||||
|
||||
// GERAR RELATÓRIO PDF POR PERÍODO
|
||||
router.get('/report/pdf', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate, deviceId } = req.query;
|
||||
const company_id = req.user.company_id;
|
||||
|
||||
console.log(`📄 Requisição de relatório PDF - Company: ${company_id}, Start: ${startDate}, End: ${endDate}, Device: ${deviceId || 'Todos'}`);
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
console.log('❌ Datas não fornecidas');
|
||||
return res.status(400).json({ error: 'Data inicial e data final são obrigatórias' });
|
||||
}
|
||||
|
||||
// Construir query com filtros
|
||||
let queryText = `
|
||||
SELECT a.*, d.id as device_table_id, d.device_id as device_string_id, d.device_name, d.hostname, u.name as user_name, u.email as user_email
|
||||
FROM activities a
|
||||
JOIN devices d ON a.device_id = d.id
|
||||
LEFT JOIN users u ON d.user_id = u.id
|
||||
WHERE a.company_id = $1
|
||||
AND a.timestamp >= $2::timestamp
|
||||
AND a.timestamp <= $3::timestamp
|
||||
`;
|
||||
const queryParams = [company_id, startDate, endDate];
|
||||
|
||||
if (deviceId) {
|
||||
queryText += ` AND d.id = $4`;
|
||||
queryParams.push(parseInt(deviceId));
|
||||
}
|
||||
|
||||
queryText += ` ORDER BY a.timestamp DESC`;
|
||||
|
||||
console.log(`📊 Buscando atividades...`);
|
||||
const result = await query(queryText, queryParams);
|
||||
const activities = result.rows;
|
||||
console.log(`✅ ${activities.length} atividades encontradas`);
|
||||
|
||||
// Buscar histórico de navegação no período (com tratamento de erro)
|
||||
let browsingHistory = [];
|
||||
try {
|
||||
let browsingQuery = `
|
||||
SELECT bh.*, d.device_name, d.hostname
|
||||
FROM browsing_history bh
|
||||
JOIN devices d ON bh.device_id = d.device_id
|
||||
WHERE d.company_id = $1
|
||||
AND bh.visited_at >= $2::timestamp
|
||||
AND bh.visited_at <= $3::timestamp
|
||||
`;
|
||||
const browsingParams = [company_id, startDate, endDate];
|
||||
|
||||
if (deviceId) {
|
||||
// Se deviceId foi fornecido, precisamos buscar o device_id string correspondente
|
||||
const deviceCheck = await query('SELECT device_id FROM devices WHERE id = $1', [parseInt(deviceId)]);
|
||||
if (deviceCheck.rows.length > 0) {
|
||||
browsingQuery += ` AND bh.device_id = $4`;
|
||||
browsingParams.push(deviceCheck.rows[0].device_id);
|
||||
}
|
||||
}
|
||||
|
||||
browsingQuery += ` ORDER BY bh.visited_at DESC LIMIT 500`;
|
||||
|
||||
console.log(`📊 Buscando histórico de navegação...`);
|
||||
const browsingResult = await query(browsingQuery, browsingParams);
|
||||
browsingHistory = browsingResult.rows;
|
||||
console.log(`✅ ${browsingHistory.length} URLs encontradas`);
|
||||
} catch (browsingError) {
|
||||
console.error(`⚠️ Erro ao buscar histórico de navegação (continuando sem histórico):`, browsingError.message);
|
||||
// Continua sem histórico de navegação se houver erro
|
||||
browsingHistory = [];
|
||||
}
|
||||
|
||||
// Criar PDF
|
||||
console.log(`📄 Criando documento PDF...`);
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
|
||||
// Configurar headers para download ANTES de fazer pipe
|
||||
const filename = `relatorio_atividades_${startDate}_${endDate}.pdf`;
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
||||
|
||||
// Pipe para response
|
||||
doc.pipe(res);
|
||||
|
||||
// Tratamento de erros no stream do PDF
|
||||
doc.on('error', (err) => {
|
||||
console.error('❌ Erro no stream do PDF:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Erro ao gerar PDF' });
|
||||
}
|
||||
});
|
||||
|
||||
// Cabeçalho
|
||||
doc.fontSize(20).text('Relatório de Atividades', { align: 'center' });
|
||||
doc.moveDown();
|
||||
doc.fontSize(12).text(`Período: ${new Date(startDate).toLocaleDateString('pt-BR')} até ${new Date(endDate).toLocaleDateString('pt-BR')}`, { align: 'center' });
|
||||
doc.moveDown(2);
|
||||
|
||||
// Estatísticas
|
||||
const totalActivities = activities.length;
|
||||
const totalBrowsing = browsingHistory.length;
|
||||
const uniqueDevices = [...new Set(activities.map(a => a.device_name))].length;
|
||||
|
||||
doc.fontSize(14).text('Estatísticas', { underline: true });
|
||||
doc.fontSize(10);
|
||||
doc.text(`Total de Atividades: ${totalActivities}`);
|
||||
doc.text(`Total de URLs Visitadas: ${totalBrowsing}`);
|
||||
doc.text(`Dispositivos Monitorados: ${uniqueDevices}`);
|
||||
doc.moveDown(2);
|
||||
|
||||
// Atividades
|
||||
if (activities.length > 0) {
|
||||
doc.fontSize(14).text('Atividades Registradas', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
let yPosition = doc.y;
|
||||
const pageHeight = doc.page.height;
|
||||
const margin = 50;
|
||||
const rowHeight = 15;
|
||||
let isFirstPage = true;
|
||||
|
||||
activities.forEach((activity, index) => {
|
||||
// Verificar se precisa de nova página
|
||||
if (yPosition + rowHeight * 3 > pageHeight - margin) {
|
||||
doc.addPage();
|
||||
yPosition = margin;
|
||||
isFirstPage = false;
|
||||
}
|
||||
|
||||
const date = new Date(activity.timestamp).toLocaleString('pt-BR');
|
||||
const device = activity.device_name || activity.hostname || 'N/A';
|
||||
const app = activity.application_name || 'N/A';
|
||||
const window = activity.window_title || 'N/A';
|
||||
const idle = `${activity.idle_time_seconds || 0}s`;
|
||||
const user = activity.user_name ? `${activity.user_name} (${activity.user_email})` : 'N/A';
|
||||
|
||||
doc.fontSize(9);
|
||||
doc.text(`${date}`, 50, yPosition);
|
||||
doc.text(`Dispositivo: ${device}`, 200, yPosition);
|
||||
doc.text(`Usuário: ${user}`, 350, yPosition);
|
||||
yPosition += 12;
|
||||
|
||||
doc.text(`Aplicativo: ${app}`, 50, yPosition);
|
||||
doc.text(`Janela: ${window}`, 200, yPosition);
|
||||
doc.text(`Ociosidade: ${idle}`, 450, yPosition);
|
||||
yPosition += 15;
|
||||
|
||||
// Linha separadora
|
||||
doc.moveTo(50, yPosition).lineTo(550, yPosition).stroke();
|
||||
yPosition += 5;
|
||||
});
|
||||
} else {
|
||||
doc.fontSize(12).text('Nenhuma atividade registrada no período selecionado.');
|
||||
}
|
||||
|
||||
// Histórico de Navegação
|
||||
if (browsingHistory.length > 0) {
|
||||
doc.addPage();
|
||||
doc.fontSize(14).text('Histórico de Navegação', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
let yPos = doc.y;
|
||||
browsingHistory.slice(0, 200).forEach((item) => {
|
||||
if (yPos + 40 > doc.page.height - margin) {
|
||||
doc.addPage();
|
||||
yPos = margin;
|
||||
}
|
||||
|
||||
const date = new Date(item.visited_at).toLocaleString('pt-BR');
|
||||
const device = item.device_name || item.device_id || 'N/A';
|
||||
const browser = item.browser || 'N/A';
|
||||
const url = item.url || 'N/A';
|
||||
const title = item.title || 'N/A';
|
||||
|
||||
doc.fontSize(9);
|
||||
doc.text(`${date} - ${device} - ${browser}`, 50, yPos);
|
||||
yPos += 12;
|
||||
doc.text(`URL: ${url}`, 50, yPos, { width: 500 });
|
||||
yPos += 12;
|
||||
doc.text(`Título: ${title}`, 50, yPos, { width: 500 });
|
||||
yPos += 15;
|
||||
|
||||
doc.moveTo(50, yPos).lineTo(550, yPos).stroke();
|
||||
yPos += 5;
|
||||
});
|
||||
}
|
||||
|
||||
// Rodapé será adicionado após finalizar o documento
|
||||
// (pdfkit não permite adicionar rodapé dinamicamente durante a criação)
|
||||
|
||||
// Finalizar PDF
|
||||
console.log(`📄 Finalizando PDF...`);
|
||||
doc.end();
|
||||
|
||||
console.log(`✅ Relatório PDF gerado com sucesso: ${totalActivities} atividades, ${totalBrowsing} URLs`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao gerar relatório PDF:', error);
|
||||
console.error('❌ Stack:', error.stack);
|
||||
|
||||
// Se os headers já foram enviados, não podemos enviar JSON
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
error: 'Erro ao gerar relatório PDF',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
} else {
|
||||
// Se já começou a enviar o PDF, apenas logar o erro
|
||||
console.error('⚠️ Headers já enviados, não é possível retornar erro JSON');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user