Files
rx.scoreodonto.com-client/client-monitor.js
T

1489 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const io = require('socket.io-client');
const chokidar = require('chokidar');
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const http = require('http');
const { exec } = require('child_process');
const sharp = require('sharp');
// Função para comprimir imagem PNG/JPEG de alta resolução para JPEG otimizado
async function comprimirImagem(buffer) {
return await sharp(buffer)
.resize({
width: 1600,
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({
quality: 75,
mozjpeg: true,
progressive: true
})
.toBuffer();
}
// Resolve dynamic folder in AppData or default path passed by Electron
const appDataFolder = process.env.DENTAL_CONFIG_DIR || path.join(
process.env.APPDATA ||
(process.platform === 'darwin' ? path.join(process.env.HOME, 'Library', 'Application Support') : path.join(process.env.HOME, '.config')),
'ScoreOdonto Dental Client'
);
if (!fs.existsSync(appDataFolder)) {
fs.mkdirSync(appDataFolder, { recursive: true });
}
// Carregar configuração dinamicamente para poder atualizá-la
const configPath = path.join(appDataFolder, 'config.json');
let config = {};
let configChanged = false;
try {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (e) {
config = {
serverUrl: "https://rx.scoreodonto.com",
monitorPath: "C:\\ProgramData\\RF\\Dental Sensor\\Images",
clientName: "COMPUTADOR-LOCAL",
clientType: "windows",
apiKey: "rf-dental-secure-key-2026",
adminUsername: "admin",
adminPassword: "admin"
};
configChanged = true;
}
// Garantir que temos um clientId exclusivo para este computador
if (!config.clientId) {
const crypto = require('crypto');
config.clientId = crypto.randomUUID ? crypto.randomUUID() : 'client_' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
configChanged = true;
}
if (configChanged) {
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
} catch (err) {
console.error('Erro ao inicializar clientId no config.json:', err.message);
}
}
// Função para verificar se o nome do cliente está disponível no servidor remoto
async function checkClientNameAvailability(serverUrl, clientName, apiKey, clientId) {
return new Promise((resolve) => {
try {
let base = serverUrl.trim();
if (base.endsWith('/')) {
base = base.slice(0, -1);
}
const checkUrl = `${base}/api/clients/check-name?name=${encodeURIComponent(clientName.trim())}&token=${encodeURIComponent(apiKey || '')}&clientId=${encodeURIComponent(clientId || '')}`;
const clientModule = checkUrl.startsWith('https') ? require('https') : require('http');
const req = clientModule.get(checkUrl, { timeout: 6000 }, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
try {
if (res.statusCode === 200) {
const data = JSON.parse(body);
if (data.success) {
resolve({ success: true, available: data.available });
return;
}
}
const errMsg = res.statusCode === 401 ? 'Chave de API inválida' : `Código HTTP ${res.statusCode}`;
resolve({ success: false, error: errMsg });
} catch (e) {
resolve({ success: false, error: `Erro de parse: ${e.message}` });
}
});
});
req.on('error', (err) => {
resolve({ success: false, error: err.message });
});
req.on('timeout', () => {
req.destroy();
resolve({ success: false, error: 'Tempo limite esgotado' });
});
} catch (error) {
resolve({ success: false, error: error.message });
}
});
}
// Variáveis de estado global
let socket;
let isConnected = false;
let isIdentified = false;
let watcher;
function sendStatusToParent() {
if (process.send) {
process.send({
event: 'status',
connected: isConnected,
identified: isIdentified
});
}
}
let isSyncing = false;
const recentLogs = [];
// Instancia única global do banco local para evitar sobrecarga de I/O
let localDbInstance = null;
function getLocalDb() {
if (!localDbInstance) {
try {
const { DatabaseSync } = require('node:sqlite');
const dbPath = path.join('C:', 'ProgramData', 'RF', 'Dental Sensor', 'data');
if (fs.existsSync(dbPath)) {
localDbInstance = new DatabaseSync(dbPath);
}
} catch (e) {
console.error('Erro ao abrir banco local:', e.message);
}
}
return localDbInstance;
}
// Cache local de uploads concluídos para evitar reenvio de arquivos no scan inicial
const uploadCachePath = path.join(appDataFolder, 'uploaded_cache.json');
let uploadedCache = new Set();
try {
if (fs.existsSync(uploadCachePath)) {
const cachedFiles = JSON.parse(fs.readFileSync(uploadCachePath, 'utf8'));
uploadedCache = new Set(cachedFiles);
console.log(`💾 Cache de uploads carregado com ${uploadedCache.size} arquivos.`);
}
} catch (e) {
console.error('Erro ao carregar cache de uploads:', e.message);
}
function saveUploadCache() {
try {
fs.writeFileSync(uploadCachePath, JSON.stringify([...uploadedCache], null, 2));
} catch (e) {
console.error('Erro ao salvar cache de uploads:', e.message);
}
}
// Função auxiliar para registrar logs locais e expor no painel
function addLog(msg) {
const timestamp = new Date().toLocaleTimeString();
const formatted = `[${timestamp}] ${msg}`;
console.log(formatted);
recentLogs.push(formatted);
if (recentLogs.length > 50) {
recentLogs.shift();
}
if (process.send) {
process.send({ event: 'log', log: formatted });
}
}
// Interface Web de Administração em HTML/CSS (zero dependências)
function getAdminHtml() {
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Dental Client - Painel</title>
<link rel="icon" type="image/png" href="/favicon.png">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0a12;
--card: rgba(18, 16, 28, 0.85);
--border: rgba(255, 255, 255, 0.08);
--primary: #7c4dff;
--primary-hover: #651fff;
--accent: #00e5ff;
--success: #00e676;
--danger: #ff1744;
--text: #f5f5f7;
--muted: #8e8e93;
--mono: 'JetBrains Mono', monospace;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-tap-highlight-color: transparent;
}
html {
height: 100%;
overflow-x: hidden;
}
body {
background: var(--bg);
background-image:
radial-gradient(at 0% 0%, rgba(124, 77, 255, 0.2) 0px, transparent 60%),
radial-gradient(at 100% 100%, rgba(0, 229, 255, 0.12) 0px, transparent 60%);
background-attachment: fixed;
color: var(--text);
font-family: 'Inter', sans-serif;
min-height: 100dvh;
padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0);
-webkit-font-smoothing: antialiased;
}
/* ---- LAYOUT ---- */
.page {
width: 100%;
max-width: 980px;
margin: 0 auto;
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
@media (min-width: 768px) {
.page {
padding: 28px 24px;
gap: 20px;
display: grid;
grid-template-columns: 1.3fr 0.7fr;
grid-template-areas:
"header header"
"config status"
"logs logs";
}
}
/* ---- CARD ---- */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 18px 16px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
@media (min-width: 768px) {
.card { padding: 24px 22px; border-radius: 20px; }
}
.card-header { grid-area: header; }
.card-config { grid-area: config; }
.card-status { grid-area: status; }
.card-logs { grid-area: logs; }
/* ---- HEADER CARD ---- */
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon { font-size: 2rem; line-height: 1; }
.logo-text h1 {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, #fff 0%, var(--muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo-text p {
font-size: 0.75rem;
color: var(--muted);
margin-top: 1px;
}
@media (min-width: 480px) {
.logo-text h1 { font-size: 1.5rem; }
.logo-text p { font-size: 0.8rem; }
}
.status-pill {
display: flex;
align-items: center;
gap: 7px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
padding: 7px 14px;
border-radius: 50px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.dot {
width: 9px; height: 9px;
border-radius: 50%;
flex-shrink: 0;
}
.dot.connected {
background: var(--success);
box-shadow: 0 0 10px var(--success);
animation: pulse 2s infinite;
}
.dot.disconnected {
background: var(--danger);
box-shadow: 0 0 10px var(--danger);
}
@keyframes pulse {
0% { transform: scale(0.9); box-shadow: 0 0 0 0 rgba(0,230,118,.7); }
70% { transform: scale(1); box-shadow: 0 0 0 8px rgba(0,230,118,0); }
100% { transform: scale(0.9); box-shadow: 0 0 0 0 rgba(0,230,118,0); }
}
/* ---- SECTION TITLE ---- */
.section-title {
font-size: 0.95rem;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
/* ---- FORM ---- */
.form-group { margin-bottom: 14px; }
label {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 6px;
font-weight: 500;
}
input {
width: 100%;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: 10px;
padding: 11px 14px;
color: #fff;
font-family: 'Inter', sans-serif;
font-size: 0.9rem;
transition: border-color 0.2s, box-shadow 0.2s;
-webkit-appearance: none;
}
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(124, 77, 255, 0.2);
background: rgba(0,0,0,0.4);
}
/* ---- BUTTONS ---- */
.btn-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 16px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: 10px;
padding: 12px 14px;
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
touch-action: manipulation;
user-select: none;
}
.btn:active { transform: scale(0.97); }
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-outline {
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); }
.btn-sm {
padding: 9px 12px;
font-size: 0.82rem;
border-radius: 8px;
width: 100%;
}
/* ---- INFO ROWS ---- */
.info-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
font-size: 0.82rem;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.info-row:last-child { border-bottom: none; }
.info-label { color: var(--muted); flex-shrink: 0; }
.info-value { font-weight: 500; color: #fff; text-align: right; word-break: break-all; }
.info-mono {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: -0.3px;
}
/* ---- ACTIONS ---- */
.actions-col {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
/* ---- TERMINAL ---- */
.terminal {
background: #050408;
border: 1px solid rgba(255,255,255,0.05);
border-radius: 10px;
padding: 12px;
height: 220px;
overflow-y: auto;
font-family: var(--mono);
font-size: 0.75rem;
line-height: 1.5;
color: #a0a0b0;
-webkit-overflow-scrolling: touch;
}
@media (min-width: 768px) {
.terminal { height: 260px; font-size: 0.8rem; }
}
.log-entry {
margin-bottom: 3px;
white-space: pre-wrap;
border-left: 2px solid rgba(124, 77, 255, 0.35);
padding-left: 8px;
word-break: break-word;
}
/* ---- TOAST ---- */
.toast {
position: fixed;
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%) translateY(120px);
padding: 12px 22px;
border-radius: 50px;
color: white;
font-weight: 600;
font-size: 0.875rem;
z-index: 9999;
display: none;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
white-space: nowrap;
max-width: calc(100vw - 32px);
animation: toastIn 0.3s cubic-bezier(.34,1.56,.64,1) forwards;
}
@keyframes toastIn {
from { transform: translateX(-50%) translateY(120px); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
.toast.success { background: #00c853; color: #000; }
.toast.error { background: var(--danger); }
.toast.info { background: var(--primary); }
/* ---- SPINNER ---- */
.spin {
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: rot 0.8s linear infinite;
display: none;
flex-shrink: 0;
}
@keyframes rot { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div id="toast" class="toast"></div>
<div class="page">
<!-- HEADER -->
<div class="card card-header">
<div class="header-inner">
<div class="logo">
<span class="logo-icon">🦷</span>
<div class="logo-text">
<h1>Dental Client</h1>
<p>Painel do Administrador</p>
</div>
</div>
<div class="status-pill">
<span id="status-dot" class="dot disconnected"></span>
<span id="status-text">Carregando...</span>
</div>
</div>
</div>
<!-- CONFIG -->
<div class="card card-config">
<div class="section-title">⚙️ Configurações do Cliente</div>
<form id="config-form" onsubmit="saveConfig(event)">
<div class="form-group">
<label for="serverUrl">🌐 URL do Servidor Destino</label>
<input type="text" id="serverUrl" placeholder="http://localhost:3000" required autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false">
</div>
<div class="form-group">
<label for="monitorPath">📁 Pasta para Monitorar</label>
<input type="text" id="monitorPath" placeholder="C:\\ProgramData\\RF\\Dental Sensor\\Images" required>
</div>
<div class="form-group">
<label for="clientName">🖥️ Nome deste Computador</label>
<input type="text" id="clientName" placeholder="PC-SALA-1" required>
</div>
<div class="form-group">
<label for="apiKey">🔑 Chave da API (Servidor VPS)</label>
<input type="password" id="apiKey" placeholder="Sua chave secreta do servidor" required>
</div>
<div class="form-group">
<label for="adminPassword">🔒 Senha do Painel Local</label>
<input type="text" id="adminPassword" placeholder="Senha para proteger esta tela" required>
</div>
<div class="btn-row">
<button type="button" class="btn btn-outline" onclick="testConnection()">
<span id="test-spin" class="spin"></span>
<span id="test-label">⚡ Testar</span>
</button>
<button type="submit" class="btn btn-primary">
<span id="save-spin" class="spin"></span>
<span id="save-label">💾 Salvar</span>
</button>
</div>
</form>
</div>
<!-- STATUS -->
<div class="card card-status">
<div class="section-title">📊 Status do Sistema</div>
<div>
<div class="info-row">
<span class="info-label">Sistema</span>
<span class="info-value" id="info-platform">-</span>
</div>
<div class="info-row">
<span class="info-label">Uptime</span>
<span class="info-value" id="info-uptime">-</span>
</div>
<div class="info-row">
<span class="info-label">Socket ID</span>
<span class="info-value info-mono" id="info-socket-id">-</span>
</div>
</div>
<div class="section-title" style="margin-top: 18px;">🛠️ Atalhos</div>
<div class="actions-col">
<button class="btn btn-outline btn-sm" onclick="openFolder()">📁 Abrir Pasta de Imagens</button>
<button class="btn btn-outline btn-sm" onclick="openServerWeb()">🔗 Abrir Painel do Servidor</button>
</div>
</div>
<!-- LOGS -->
<div class="card card-logs">
<div class="section-title">📜 Atividades Recentes</div>
<div class="terminal" id="logs-container">
<div class="log-entry">Carregando logs...</div>
</div>
</div>
</div>
<script>
let currentConfig = {};
function showToast(msg, type = 'info') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast ' + type;
t.style.display = 'block';
setTimeout(() => { t.style.display = 'none'; }, 3500);
}
async function fetchStatus() {
try {
const res = await fetch('/api/status');
const data = await res.json();
if (!currentConfig.serverUrl) {
currentConfig = data.config;
document.getElementById('serverUrl').value = data.config.serverUrl || '';
document.getElementById('monitorPath').value = data.config.monitorPath || '';
document.getElementById('clientName').value = data.config.clientName || '';
document.getElementById('apiKey').value = data.config.apiKey || '';
document.getElementById('adminPassword').value = data.config.adminPassword || '';
}
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
if (data.connected) {
dot.className = 'dot connected';
text.textContent = data.identified ? 'Identificado' : 'Conectado...';
} else {
dot.className = 'dot disconnected';
text.textContent = 'Desconectado';
}
document.getElementById('info-platform').textContent = data.platform === 'win32' ? 'Windows' : data.platform;
document.getElementById('info-uptime').textContent = fmtUptime(data.uptime);
document.getElementById('info-socket-id').textContent = data.socketId || '—';
const logsEl = document.getElementById('logs-container');
const atBottom = logsEl.scrollHeight - logsEl.clientHeight <= logsEl.scrollTop + 60;
logsEl.innerHTML = data.logs.map(l => '<div class="log-entry">' + esc(l) + '</div>').join('');
if (atBottom) logsEl.scrollTop = logsEl.scrollHeight;
} catch (e) {
document.getElementById('status-dot').className = 'dot disconnected';
document.getElementById('status-text').textContent = 'Erro';
}
}
function fmtUptime(s) {
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = Math.floor(s % 60);
return h + 'h ' + m + 'm ' + sec + 's';
}
function esc(t) {
return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
async function saveConfig(e) {
e.preventDefault();
const spin = document.getElementById('save-spin');
const lbl = document.getElementById('save-label');
spin.style.display = 'inline-block'; lbl.textContent = ' Salvando...';
try {
const r = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
serverUrl: document.getElementById('serverUrl').value.trim(),
monitorPath: document.getElementById('monitorPath').value.trim(),
clientName: document.getElementById('clientName').value.trim(),
apiKey: document.getElementById('apiKey').value.trim(),
adminPassword: document.getElementById('adminPassword').value.trim()
})
});
const d = await r.json();
if (d.success) { showToast('Configurações salvas!', 'success'); currentConfig = {}; }
else showToast('Erro: ' + d.error, 'error');
} catch (err) { showToast('Falha: ' + err.message, 'error'); }
finally { spin.style.display = 'none'; lbl.textContent = '💾 Salvar'; }
}
async function testConnection() {
const url = document.getElementById('serverUrl').value.trim();
if (!url) { showToast('Informe a URL.', 'error'); return; }
const spin = document.getElementById('test-spin');
const lbl = document.getElementById('test-label');
spin.style.display = 'inline-block'; lbl.textContent = ' Testando...';
try {
const r = await fetch('/api/test-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const d = await r.json();
if (d.success) showToast(d.message, 'success');
else showToast('Inacessível: ' + d.error, 'error');
} catch (err) { showToast('Falha: ' + err.message, 'error'); }
finally { spin.style.display = 'none'; lbl.textContent = '⚡ Testar'; }
}
async function openFolder() {
try {
const r = await fetch('/api/open-folder', { method: 'POST' });
const d = await r.json();
showToast(d.success ? 'Pasta aberta!' : 'Erro: ' + d.error, d.success ? 'success' : 'error');
} catch { showToast('Erro na requisição.', 'error'); }
}
function openServerWeb() {
const url = document.getElementById('serverUrl').value.trim();
if (url) window.open(url, '_blank');
else showToast('URL não configurada.', 'error');
}
setInterval(fetchStatus, 2000);
fetchStatus();
</script>
</body>
</html>`;
}
function startAdminServer() {
const server = http.createServer((req, res) => {
// Habilitar CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// Parse da URL
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
// Rota pública: Favicon
if (parsedUrl.pathname === '/favicon.png' && req.method === 'GET') {
const faviconPath = path.join(__dirname, 'favicon.png');
if (fs.existsSync(faviconPath)) {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(fs.readFileSync(faviconPath));
} else {
res.writeHead(404);
res.end();
}
return;
}
// --- PROTEÇÃO DO PAINEL LOCAL (Basic Auth) ---
const authHeader = req.headers['authorization'];
if (config.adminPassword && config.adminPassword.trim() !== '') {
if (!authHeader) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Dental Client Admin"' });
res.end('Acesso Negado');
return;
}
const authStr = Buffer.from(authHeader.split(' ')[1], 'base64').toString();
const [user, pass] = authStr.split(':');
// Autenticamos apenas pela senha, o usuário pode ser admin ou vazio
if (pass !== config.adminPassword) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Dental Client Admin"' });
res.end('Acesso Negado');
return;
}
}
// ---------------------------------------------
if (parsedUrl.pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(getAdminHtml());
}
else if (parsedUrl.pathname === '/api/status' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
connected: isConnected,
identified: isIdentified,
socketId: socket ? socket.id : null,
config: config,
logs: recentLogs,
platform: process.platform,
uptime: process.uptime()
}));
}
else if (parsedUrl.pathname === '/api/config' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', async () => {
try {
const newConfig = JSON.parse(body);
if (!newConfig.serverUrl || !newConfig.monitorPath || !newConfig.clientName || !newConfig.apiKey || !newConfig.adminPassword) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Campos obrigatórios ausentes.' }));
return;
}
// Verificar se o nome mudou ou se o servidor mudou para checar disponibilidade
if (newConfig.clientName !== config.clientName || newConfig.serverUrl !== config.serverUrl) {
addLog(`🔍 Verificando se o nome "${newConfig.clientName}" está disponível no servidor...`);
const check = await checkClientNameAvailability(newConfig.serverUrl, newConfig.clientName, newConfig.apiKey, config.clientId);
if (check.success && !check.available) {
addLog(`❌ O nome "${newConfig.clientName}" já está em uso no servidor.`);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: `O nome "${newConfig.clientName}" já está sendo usado por outro computador neste servidor. Escolha um nome exclusivo.` }));
return;
}
}
// Gravar no config.json
config.serverUrl = newConfig.serverUrl;
config.monitorPath = newConfig.monitorPath;
config.clientName = newConfig.clientName;
config.apiKey = newConfig.apiKey;
config.adminPassword = newConfig.adminPassword;
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
addLog(`💾 Novas configurações persistidas em config.json`);
} catch (writeErr) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Erro ao salvar config.json: ' + writeErr.message }));
return;
}
// Aplicar dinamicamente
reconnectAndRestart();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: e.message }));
}
});
}
else if (parsedUrl.pathname === '/api/test-server' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = JSON.parse(body);
const testUrl = data.url;
if (!testUrl) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'URL inválida.' }));
return;
}
const checkUrl = testUrl.endsWith('/') ? `${testUrl}status` : `${testUrl}/status`;
const clientModule = checkUrl.startsWith('https') ? require('https') : require('http');
addLog(`⚡ Testando conectividade com: ${checkUrl}`);
const testReq = clientModule.get(checkUrl, { timeout: 4000 }, (testRes) => {
let resBody = '';
testRes.on('data', chunk => resBody += chunk);
testRes.on('end', () => {
if (testRes.statusCode >= 200 && testRes.statusCode < 300) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: `Conexão bem sucedida! Código: ${testRes.statusCode}` }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: `Código de status inesperado: ${testRes.statusCode}` }));
}
});
});
testReq.on('error', (err) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: err.message }));
});
testReq.on('timeout', () => {
testReq.destroy();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: 'Timeout de 4 segundos esgotado.' }));
});
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: e.message }));
}
});
}
else if (parsedUrl.pathname === '/api/open-folder' && req.method === 'POST') {
try {
const normalizedPath = path.normalize(config.monitorPath);
if (!fs.existsSync(normalizedPath)) {
fs.mkdirSync(normalizedPath, { recursive: true });
}
exec(`explorer.exe "${normalizedPath}"`);
addLog(`📁 Pasta de imagens aberta no Explorer: ${normalizedPath}`);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: e.message }));
}
}
else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
server.listen(3001, '127.0.0.1', () => {
addLog('🌐 Painel de Administração iniciado em: http://localhost:3001');
});
}
// Aplicar alterações do painel dinamicamente
function reconnectAndRestart() {
addLog('🔄 Aplicando novas configurações dinamicamente...');
// 1. Fechar conexão socket anterior
if (socket) {
addLog('🔌 Desconectando socket ativo...');
socket.disconnect();
socket.removeAllListeners();
}
isConnected = false;
isIdentified = false;
sendStatusToParent();
// 2. Parar watcher antigo
if (watcher) {
addLog('👀 Interrompendo monitoramento anterior...');
watcher.close();
}
// 3. Conectar novamente com novas definições
connectToServer();
// 4. Reiniciar monitoramento
setTimeout(() => {
startMonitoring();
}, 1000);
}
// Função de Conexão com o Servidor Dental (Socket.IO)
function connectToServer() {
const serverUrl = process.env.SERVER_URL || config.serverUrl;
const apiKey = process.env.CLIENT_API_KEY || config.apiKey;
const serverEmail = config.serverEmail || '';
const serverPassword = config.serverPassword || '';
const token = config.token || apiKey || '';
addLog(`🔄 Conectando ao servidor: ${serverUrl}...`);
socket = io(serverUrl, {
transports: ['websocket', 'polling'],
upgrade: true,
timeout: 20000,
reconnection: true,
reconnectionDelay: 2000,
reconnectionAttempts: Infinity,
forceNew: true,
auth: {
token: token,
email: serverEmail,
password: serverPassword
}
});
socket.on('connect_error', (err) => {
addLog(`❌ Erro de Conexão: ${err.message}`);
if (err.message.includes('Authentication error')) {
addLog(`🛑 VERIFIQUE A CHAVE DA API! Acesso negado pelo servidor.`);
}
});
socket.on('connect', () => {
addLog('✅ Conectado ao servidor Socket.IO com sucesso!');
isConnected = true;
sendStatusToParent();
setTimeout(() => identifyClient(), 500);
});
socket.on('disconnect', (reason) => {
addLog(`❌ Desconectado do servidor. Motivo: ${reason}`);
isConnected = false;
isIdentified = false;
sendStatusToParent();
});
socket.on('connection-ack', (data) => {
addLog(`✅ Confirmação de socket recebida. Socket ID: ${data.socketId}`);
});
socket.on('please-identify', () => {
addLog('⚠️ Servidor solicitou identificação do cliente.');
identifyClient();
});
socket.on('server-identified', (data) => {
addLog(`✅ Identificação confirmada pelo servidor para o cliente: ${data.clientInfo?.name}`);
isIdentified = true;
sendStatusToParent();
});
// Ouvinte para o Sinal de Sincronização Remota (Enviado pelo Painel Web)
socket.on('sync-request', async (data) => {
addLog(`🔄 [Sincronização] Sinal recebido do servidor. Timestamp do sinal: ${data?.timestamp || 'N/A'}`);
try {
addLog('🚀 Iniciando varredura local de imagens do sensor...');
await executarSincronizacaoLocal();
} catch (error) {
addLog(`❌ Erro durante o processamento da sincronização remota: ${error.message}`);
}
});
socket.on('connect_error', (error) => {
addLog(`❌ Erro de conexão Socket.IO: ${error.message}`);
});
socket.on('error', (error) => {
addLog(`❌ Erro no socket: ${error.message}`);
});
socket.on('image-received', (data) => {
addLog(`✅ Imagem processada e salva no servidor! ID: ${data.imageId}, Arquivo: ${data.filename}`);
if (data.originalFilename) {
uploadedCache.add(data.originalFilename);
saveUploadCache();
}
});
socket.on('test-connection', (data) => {
addLog(`🧪 Recebido teste de conexão do servidor. Test ID: ${data?.testId}`);
const startTime = data?.timestamp || Date.now();
const latency = Date.now() - startTime;
socket.emit('test-connection-ack', {
testId: data?.testId,
socketId: socket.id,
latency: latency,
timestamp: new Date().toISOString(),
message: 'Teste de conexão respondido pelo cliente'
});
});
}
// Função para Identificar o Cliente
function identifyClient() {
if (!isConnected || isIdentified) return;
addLog(`👤 Enviando identificação: Nome=${config.clientName || 'Notebook Consultório Principal'}, Tipo=windows`);
socket.emit('client-identify', {
type: 'windows', // MANDATÓRIO: Identifica o notebook na Área de Sincronização
name: config.clientName || 'Notebook Consultório Principal', // Nome amigável que aparecerá no painel
version: '1.1.0',
system: 'windows',
path: config.monitorPath,
clientId: config.clientId // Identificador exclusivo
});
// Reenviar identificação em 3 segundos se não confirmado
setTimeout(() => {
if (!isIdentified && isConnected) {
addLog('⚠️ Confirmação de identificação pendente. Tentando novamente...');
identifyClient();
}
}, 3000);
}
// Função para executar a varredura local e sincronização remota
async function executarSincronizacaoLocal() {
if (isSyncing) {
addLog('⚠️ Sincronização local já está em execução. Ignorando nova chamada concorrente.');
return;
}
isSyncing = true;
const pastaImagensSensor = config.monitorPath;
if (!fs.existsSync(pastaImagensSensor)) {
addLog(`⚠️ Pasta de imagens não encontrada no caminho: ${pastaImagensSensor}`);
isSyncing = false;
return;
}
try {
const arquivos = fs.readdirSync(pastaImagensSensor);
const imagens = arquivos.filter(file => /\.(png|jpg|jpeg)$/i.test(file));
addLog(`🔎 Encontradas ${imagens.length} imagens locais no total. Verificando pendências com o servidor...`);
let processadas = 0;
for (const file of imagens) {
const filePath = path.join(pastaImagensSensor, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
continue;
}
try {
// sendImageToServer envia o arquivo e valida no servidor via check-image-exists, ignorando o cache local.
// Se a imagem existir no servidor, ela será adicionada ao cache e não será reenviada.
await sendImageToServer(filePath, true, true); // isBacklog = true, bypassCache = true
processadas++;
} catch (err) {
addLog(`❌ Falha ao processar imagem ${file} na sincronização: ${err.message}`);
}
}
addLog(`✅ Sincronização concluída. ${processadas} imagens locais processadas.`);
} catch (e) {
addLog(`❌ Erro durante a varredura local de imagens: ${e.message}`);
} finally {
isSyncing = false;
}
}
// Fila para processar uploads de forma sequencial
const uploadQueue = [];
let isProcessingQueue = false;
async function processQueue() {
if (isProcessingQueue) return;
isProcessingQueue = true;
while (uploadQueue.length > 0) {
const item = uploadQueue.shift();
const nextPath = item.filePath;
const isBacklog = item.isBacklog;
try {
await sendImageToServer(nextPath, isBacklog);
} catch (e) {
addLog(`⚠️ Falha ao processar upload de ${path.basename(nextPath)}: ${e.message}`);
}
}
isProcessingQueue = false;
}
// Função de Monitoramento da Pasta de Imagens (Chokidar)
function startMonitoring() {
const targetDir = config.monitorPath;
if (!fs.existsSync(targetDir)) {
addLog(`💡 Criando pasta de monitoramento: ${targetDir}`);
fs.mkdirSync(targetDir, { recursive: true });
}
addLog(`👀 Iniciando monitoramento de arquivos PNG em: ${targetDir}`);
let isReady = false;
watcher = chokidar.watch(path.join(targetDir, '**/*.png'), {
persistent: true,
ignoreInitial: false,
awaitWriteFinish: {
stabilityThreshold: 1500, // aumentar estabilidade para raios-x grandes
pollInterval: 500
}
});
watcher.on('add', (filePath) => {
// Ignorar pastas internas do servidor se estiver na raiz errada
if (filePath.includes('dental-server')) return;
uploadQueue.push({ filePath, isBacklog: !isReady });
processQueue();
});
watcher.on('ready', () => {
isReady = true;
addLog('✅ Monitoramento Chokidar pronto e aguardando novos raios-X!');
});
watcher.on('error', (error) => {
addLog(`❌ Erro no Chokidar: ${error.message}`);
});
}
// Função para buscar dados do paciente no banco SQLite local do Dental Sensor
function lookupPatientInfo(fileName) {
try {
const db = getLocalDb();
if (!db) {
return null;
}
// Tentar busca exata pelo nome do arquivo
let img = db.prepare("SELECT * FROM Images WHERE LOWER(FileName) = LOWER(?)").get(fileName);
// Se não encontrar, tentar busca por prefixo (primeiros 10 caracteres do GUID)
if (!img) {
const baseName = fileName.replace(/\.png$/i, '').toLowerCase();
if (baseName.length >= 10) {
const prefix = baseName.substring(0, 10);
img = db.prepare("SELECT * FROM Images WHERE FileName LIKE ? LIMIT 1").get(`%${prefix}%`);
}
}
if (img) {
let photographTime = img.PhotographTime;
if (photographTime && photographTime.includes('.')) {
photographTime = photographTime.split('.')[0];
}
const patient = db.prepare("SELECT * FROM Patients WHERE Id = ?").get(img.PatientId);
const firstName = patient ? (patient.FirstName || '').trim() : '';
const lastName = patient ? (patient.LastName || '').trim() : '';
const fullName = `${firstName} ${lastName}`.trim() || 'Sem nome';
const doctor = patient ? (patient.Doctor || '').trim() || 'Sem médico' : 'Sem médico';
const remark = patient ? (patient.Remark || '').trim() || 'Sem observação' : 'Sem observação';
return { name: fullName, doctor, remark, photographTime };
}
} catch (e) {
addLog(`⚠️ Erro ao buscar dados do paciente no banco local: ${e.message}`);
}
return null;
}
// Enviar imagem para o servidor via Socket.IO e aguardar confirmação
function sendImageToServer(filePath, isBacklog, bypassCache = false) {
return new Promise(async (resolve, reject) => {
try {
if (!isConnected) {
addLog(`⚠️ Falha ao enviar: Sem conexão com o servidor.`);
resolve();
return;
}
if (!isIdentified) {
identifyClient();
await new Promise(r => setTimeout(r, 1200));
}
const fileName = path.basename(filePath);
if (!bypassCache && uploadedCache.has(fileName)) {
resolve();
return;
}
// Verificar no servidor se a imagem já existe antes de ler e codificar em base64
const existsOnServer = await new Promise((res) => {
socket.emit('check-image-exists', { fileName }, (resData) => {
if (resData && resData.success && resData.exists) {
res(true);
} else {
res(false);
}
});
// Timeout de segurança para a verificação
setTimeout(() => res(false), 5000);
});
if (existsOnServer) {
addLog(`️ Imagem ${fileName} já existe no servidor. Adicionando ao cache local.`);
uploadedCache.add(fileName);
saveUploadCache();
resolve();
return;
}
let fileBuffer = fs.readFileSync(filePath);
const originalSize = fileBuffer.length;
const isImage = /\.(png|jpg|jpeg)$/i.test(fileName);
if (isImage) {
try {
fileBuffer = await comprimirImagem(fileBuffer);
addLog(`⚡ Imagem comprimida com sucesso: ${fileName} (${Math.round(originalSize / 1024)} KB -> ${Math.round(fileBuffer.length / 1024)} KB)`);
} catch (compressErr) {
addLog(`⚠️ Falha na compressão da imagem ${fileName}, enviando original: ${compressErr.message}`);
}
}
let patientName = "Cliente não identificado";
let doctor = 'Sem médico';
let remark = 'Sem observação';
let photographTime = null;
const localInfo = lookupPatientInfo(fileName);
if (localInfo) {
patientName = localInfo.name;
doctor = localInfo.doctor;
remark = localInfo.remark;
photographTime = localInfo.photographTime;
addLog(`🔍 Dados do paciente encontrados localmente: "${patientName}", Dentista: "${doctor}", Obs: "${remark}", Data: "${photographTime}"`);
} else {
addLog(`🔍 Nenhum registro no banco local para o arquivo: ${fileName}`);
try {
const stat = fs.statSync(filePath);
const fileDate = stat.mtime || stat.birthtime || new Date();
const pad = (num) => String(num).padStart(2, '0');
photographTime = `${fileDate.getFullYear()}-${pad(fileDate.getMonth() + 1)}-${pad(fileDate.getDate())} ${pad(fileDate.getHours())}:${pad(fileDate.getMinutes())}:${pad(fileDate.getSeconds())}`;
addLog(`️ Usando data de modificação do arquivo como fallback: "${photographTime}"`);
} catch (e) {
addLog(`⚠️ Erro ao ler metadados do arquivo: ${e.message}`);
}
}
addLog(`📤 Enviando: ${fileName} (${Math.round(fileBuffer.length / 1024)} KB) ${isBacklog ? '[Backlog]' : '[Novo]'}`);
// Definir timeout de 45 segundos para segurança
let didTimeout = false;
const timeoutId = setTimeout(() => {
didTimeout = true;
reject(new Error(`Timeout de 45 segundos aguardando resposta do servidor para: ${fileName}`));
}, 45000);
socket.emit('send-image', {
imageBase64: fileBuffer.toString('base64'),
isBacklog: isBacklog,
metadata: {
fileName: fileName,
guid: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
fileSize: fileBuffer.length
},
patientData: {
name: patientName,
doctor: doctor,
remark: remark,
photographTime: photographTime
}
}, (response) => {
if (didTimeout) return;
clearTimeout(timeoutId);
if (response && response.success) {
addLog(`✅ Confirmação do servidor recebida: ${response.message || 'sucesso'}`);
if (response.originalFilename) {
uploadedCache.add(response.originalFilename);
saveUploadCache();
}
resolve();
} else {
reject(new Error(response ? response.error : 'Erro desconhecido do servidor'));
}
});
} catch (error) {
addLog(`❌ Erro ao ler/enviar arquivo: ${error.message}`);
reject(error);
}
});
}
// Configuração interativa via terminal CLI (fallback)
function runSetupWizard() {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log('\n=======================================================');
console.log('⚙️ CONFIGURAÇÃO DO DENTAL CLIENT');
console.log('=======================================================');
console.log('Pressione ENTER para manter o valor atual entre colchetes.\n');
rl.question(`🌐 IP/URL do Servidor Destino [${config.serverUrl}]: `, (urlInput) => {
config.serverUrl = urlInput.trim() || config.serverUrl;
rl.question(`📁 Pasta para Monitorar Imagens [${config.monitorPath}]: `, (pathInput) => {
config.monitorPath = pathInput.trim() || config.monitorPath;
const askName = () => {
rl.question(`🖥️ Nome deste Computador [${config.clientName}]: `, async (nameInput) => {
const chosenName = nameInput.trim() || config.clientName;
console.log(`🔍 Verificando se o nome "${chosenName}" está disponível no servidor...`);
const check = await checkClientNameAvailability(config.serverUrl, chosenName, config.apiKey, config.clientId);
if (check.success && !check.available) {
console.log(`❌ O nome "${chosenName}" JÁ está sendo usado no servidor!`);
console.log(`⚠️ Escolha outro nome exclusivo para evitar duplicidade de buckets ou vazamento de dados.\n`);
askName();
} else {
if (!check.success) {
console.log(`⚠️ Não foi possível validar o nome no servidor (${check.error}).`);
console.log(` Prosseguindo com o nome "${chosenName}" por precaução...\n`);
} else {
console.log(`✅ Nome "${chosenName}" disponível!\n`);
}
config.clientName = chosenName;
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
console.log('═══════════════════════════════════════════════════════');
console.log('✅ CONFIGURAÇÃO SALVA COM SUCESSO EM config.json!');
console.log(` Servidor Destino: ${config.serverUrl}`);
console.log(` Pasta Monitorada: ${config.monitorPath}`);
console.log(` Nome do Cliente : ${config.clientName}`);
console.log('═══════════════════════════════════════════════════════\n');
} catch (err) {
console.error('❌ Erro ao salvar config.json:', err.message);
}
rl.close();
resolve();
}
});
};
askName();
});
});
});
}
// Função Principal
async function main() {
const args = process.argv.slice(2);
const needsSetup = args.includes('--setup') || args.includes('--config') || !config.serverUrl;
if (needsSetup) {
await runSetupWizard();
}
addLog('🦷 DENTAL CLIENT - Monitor de Imagens');
addLog('═══════════════════════════════════════════════════════');
addLog(`📁 Monitorando: ${config.monitorPath}`);
addLog(`🌐 Servidor : ${config.serverUrl}`);
addLog(`🖥️ Cliente : ${config.clientName}`);
addLog('═══════════════════════════════════════════════════════');
// Iniciar servidor HTTP de administração local apenas se não rodar no Electron
if (!process.send) {
startAdminServer();
} else {
sendStatusToParent();
}
// Conectar ao Socket.IO
connectToServer();
// Iniciar monitor de arquivos após 2 segundos
setTimeout(startMonitoring, 2000);
}
main();
// Encerrar processos de forma limpa
process.on('SIGINT', () => {
addLog('🛑 Sinal de encerramento recebido. Desconectando e saindo...');
if (socket) socket.disconnect();
if (watcher) watcher.close();
process.exit(0);
});
process.on('uncaughtException', (error) => {
addLog(`❌ Erro não capturado no processo: ${error.message}`);
});
if (process.send) {
process.on('message', async (msg) => {
if (msg && msg.command === 'sync') {
addLog('🔄 [IPC] Recebida solicitação de varredura local...');
await executarSincronizacaoLocal();
}
});
}