1489 lines
57 KiB
JavaScript
1489 lines
57 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
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();
|
||
}
|
||
});
|
||
}
|