Files
AidsMonitoring/frontend/js/menu.js
T
alro65 cfd94f905a security: CORS hardening, path traversal fix, WebSocket auth + cleanup
- Restrict CORS to localhost origins (was allow_origins=[*])
- Require valid JWT on WebSocket /ws (anonymous no longer gets admin view)
- Fix path traversal in delete_cell(): resolve() + parent check
- Validate cell_id format in /charts/download-noaa/{cell_id}
- Exclude charts/ and Cartas/ from git (keep US1GC09M world overview)
- Add NOAA ENC Portal external link in charts catalog tab
- Untrack __pycache__/, .db, .claude/ session files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:45:43 -04:00

1638 lines
73 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.
'use strict';
// ── Menú desplegable ──────────────────────────────────────────────────────────
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = item.classList.contains('open');
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('open'));
if (!isOpen) item.classList.add('open');
});
});
document.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('open'));
});
// ── Acciones del menú ─────────────────────────────────────────────────────────
document.querySelectorAll('.dd-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('open'));
const action = item.dataset.action;
switch (action) {
case 'users-list':
case 'users-create':
if (!window.Auth?.isSuperAdmin()) {
alert('Superadmin access required for user management.');
return;
}
openUsersModal(action === 'users-create');
break;
case 'charts-catalog':
openChartsModal('tab-installed');
break;
case 'charts-install':
openChartsModal('tab-upload');
break;
case 'charts-noaa':
openChartsModal('tab-catalog');
break;
case 'settings-datasource':
openSettingsModal('ais');
break;
case 'settings-station':
openSettingsModal('station');
break;
case 'settings-alerts':
openSettingsModal('alerts');
break;
case 'settings-equipment':
openSettingsModal('equipment');
break;
case 'lamps-catalog':
openLampsModal();
break;
case 'contacts-catalog':
openContactsModal();
break;
case 'recordings-list':
openRecordingsModal();
break;
case 'ais-history':
if (window.openTrackHistoryModal) openTrackHistoryModal();
else openRecordingsModal();
break;
case 'org-management':
if (window.Auth?.isSuperAdmin() || window.Auth?.isAdmin()) {
if (window.openOrgModal) openOrgModal();
} else {
alert('Admin access required.');
}
break;
case 'export-data':
alert('CSV export coming soon.');
break;
}
});
});
// ── MODAL USUARIOS ────────────────────────────────────────────────────────────
async function openUsersModal(openCreate = false) {
_showModal('modal-users');
await loadUsersList();
if (openCreate) openUserForm();
}
async function loadUsersList() {
const body = document.getElementById('modal-users-body');
body.innerHTML = '<div style="color:var(--text-muted);padding:8px">Loading...</div>';
try {
const res = await fetch(`${API}/auth/users`, {
headers: { Authorization: `Bearer ${window.Auth.token()}` }
});
if (res.status === 401) { body.innerHTML = '<div style="color:var(--red);padding:8px">Session expired — please log in again (F5).</div>'; return; }
const data = await res.json();
const users = Array.isArray(data) ? data : [];
// Also fetch companies to resolve company_id → name
let companyMap = {};
try {
const cr = await fetch(`${API}/org/companies`,
{ headers: { Authorization: `Bearer ${window.Auth.token()}` } });
if (cr.ok) {
const cs = await cr.json();
cs.forEach(c => { companyMap[c.id] = c.name; });
window._orgPortsCache = window._orgPortsCache || []; // used by company hint
}
} catch {}
body.innerHTML = `
<table class="users-table">
<thead>
<tr>
<th>USERNAME</th><th>FULL NAME</th><th>EMAIL</th>
<th>ROLE</th><th>COMPANY</th><th>STATUS</th><th></th>
</tr>
</thead>
<tbody>
${users.map(u => `
<tr data-username="${u.username}">
<td class="mono">${u.username}</td>
<td>${u.nombre}</td>
<td class="mono" style="font-size:0.72rem">${u.email || '--'}</td>
<td><span class="session-role ${roleClass(u.role)}">${u.role}</span></td>
<td style="font-size:0.72rem;color:var(--text-muted)">${u.company_id ? (companyMap[u.company_id] || u.company_id) : '--'}</td>
<td><span style="color:${u.activo ? 'var(--green)' : 'var(--red)'}">${u.activo ? 'ACTIVE' : 'INACTIVE'}</span></td>
<td style="display:flex;gap:4px;justify-content:flex-end">
<button class="chart-row-btn" onclick="editUser('${u.username}','${u.nombre}','${u.email||''}','${u.role}','${u.company_id||''}',${u.activo})">EDIT</button>
${u.username !== 'admin' ? `<button class="chart-row-btn danger" onclick="deleteUser('${u.username}')">DELETE</button>` : ''}
</td>
</tr>`).join('')}
</tbody>
</table>
`;
} catch (e) {
body.innerHTML = `<div class="modal-error" style="display:block">Error loading users: ${e.message}</div>`;
}
}
function roleClass(role) {
return {
SUPERADMIN: 'role-superadmin',
ADMIN: 'role-admin',
CLIENT_ADMIN: 'role-admin', // same cyan badge as admin but label differs
USER: 'role-user',
}[role] || 'role-user';
}
// ── MODAL CREAR USUARIO ───────────────────────────────────────────────────────
let _ufCompanies = []; // cached list from /org/companies
async function _loadCompaniesForForm() {
try {
const r = await fetch(`${API}/org/companies`,
{ headers: { Authorization: `Bearer ${window.Auth?.token()}` } });
_ufCompanies = await r.json();
} catch { _ufCompanies = []; }
const sel = document.getElementById('uf-company');
sel.innerHTML = '<option value="">— Select company —</option>' +
_ufCompanies.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
}
function _ufToggleCompanyRow() {
const role = document.getElementById('uf-role').value;
const row = document.getElementById('uf-company-row');
// Company field required for client roles (USER and CLIENT_ADMIN)
row.style.display = (role === 'USER' || role === 'CLIENT_ADMIN') ? '' : 'none';
}
document.getElementById('uf-role').addEventListener('change', _ufToggleCompanyRow);
// Auto-suggest username & full-name when a company is selected
document.getElementById('uf-company').addEventListener('change', () => {
const cid = document.getElementById('uf-company').value;
const company = _ufCompanies.find(c => c.id === cid);
const hint = document.getElementById('uf-company-hint');
if (!company) { hint.textContent = ''; return; }
// Port name for the hint
const portName = company.port_id
? (window._orgPortsCache?.find?.(p => p.id === company.port_id)?.name || company.port_id)
: '';
hint.textContent = portName ? `Home port: ${portName}` : '';
// Auto-fill username: slug from company name (lowercase, no spaces)
const slug = company.name.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '');
const usernameEl = document.getElementById('uf-username');
if (!usernameEl.readOnly && !usernameEl.value) {
usernameEl.value = slug;
}
// Auto-fill full name if empty
const nombreEl = document.getElementById('uf-nombre');
if (!nombreEl.value) {
nombreEl.value = company.name + (portName ? ` — ${portName}` : '');
}
});
async function openUserForm() {
document.getElementById('user-form-title').textContent = 'CREATE USER';
document.getElementById('uf-username').value = '';
document.getElementById('uf-username').readOnly = false;
document.getElementById('uf-nombre').value = '';
document.getElementById('uf-email').value = '';
document.getElementById('uf-password').value = '';
document.getElementById('uf-role').value = 'USER';
document.getElementById('uf-company').value = '';
document.getElementById('uf-company-hint').textContent = '';
document.getElementById('uf-error').classList.add('hidden');
document.getElementById('btn-uf-save').dataset.editMode = '';
_ufToggleCompanyRow();
await _loadCompaniesForForm();
_showModal('modal-user-form');
}
document.getElementById('btn-user-new').addEventListener('click', openUserForm);
window.editUser = function(username, nombre, email, role, company_id, activo) {
document.getElementById('user-form-title').textContent = `EDIT — ${username}`;
document.getElementById('uf-username').value = username;
document.getElementById('uf-username').readOnly = true;
document.getElementById('uf-nombre').value = nombre;
document.getElementById('uf-email').value = email;
document.getElementById('uf-password').value = '';
document.getElementById('uf-role').value = role;
document.getElementById('uf-company').value = company_id || '';
document.getElementById('uf-company-hint').textContent = '';
document.getElementById('uf-error').classList.add('hidden');
document.getElementById('btn-uf-save').dataset.editMode = '1';
_ufToggleCompanyRow();
_loadCompaniesForForm().then(() => {
document.getElementById('uf-company').value = company_id || '';
});
_showModal('modal-user-form');
};
window.deleteUser = async function(username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try {
const res = await fetch(`${API}/auth/users/${username}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${window.Auth.token()}` },
});
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
await loadUsersList();
} catch (e) { alert('Error: ' + e.message); }
};
document.getElementById('btn-uf-save').addEventListener('click', async () => {
const username = document.getElementById('uf-username').value.trim();
const nombre = document.getElementById('uf-nombre').value.trim();
const email = document.getElementById('uf-email').value.trim();
const password = document.getElementById('uf-password').value;
const role = document.getElementById('uf-role').value;
const company_id = document.getElementById('uf-company').value || null;
const errEl = document.getElementById('uf-error');
const isEdit = document.getElementById('btn-uf-save').dataset.editMode === '1';
// Company required for USER role
if (role === 'USER' && !company_id) {
errEl.textContent = 'Select a company for USER accounts.';
errEl.classList.remove('hidden');
return;
}
if (!isEdit && (!username || !nombre || !password)) {
errEl.textContent = 'Username, full name and password are required.';
errEl.classList.remove('hidden');
return;
}
if (isEdit && !nombre) {
errEl.textContent = 'Full name is required.';
errEl.classList.remove('hidden');
return;
}
try {
let res;
if (isEdit) {
const body = { nombre, email: email || null, role, company_id };
if (password) body.password = password;
res = await fetch(`${API}/auth/users/${username}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.Auth.token()}` },
body: JSON.stringify(body),
});
} else {
res = await fetch(`${API}/auth/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${window.Auth.token()}` },
body: JSON.stringify({ username, nombre, email: email || null, password, role, company_id }),
});
}
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Error saving user');
}
document.getElementById('uf-username').readOnly = false;
document.getElementById('btn-uf-save').dataset.editMode = '';
_hideModal('modal-user-form');
_showModal('modal-users');
await loadUsersList();
} catch (e) {
errEl.textContent = e.message;
errEl.classList.remove('hidden');
}
});
// ── MODAL CARTAS ──────────────────────────────────────────────────────────────
function openChartsModal(tab = 'tab-catalog') {
document.getElementById('chart-install-status').innerHTML = '';
document.getElementById('chart-save-status').textContent = '';
document.getElementById('chart-file').value = '';
_showModal('modal-charts');
_activateChartTab(tab);
if (tab === 'tab-catalog' || tab === 'tab-installed') loadNOAACatalog();
if (tab === 'tab-installed') loadInstalledCells();
}
// Chart tabs
document.querySelectorAll('.stab[data-ctab]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.ctab;
_activateChartTab(t);
if (t === 'tab-installed') loadInstalledCells();
if (t === 'tab-catalog') loadNOAACatalog();
});
});
function _activateChartTab(id) {
document.querySelectorAll('.ctab-panel').forEach(p => p.classList.add('hidden'));
document.querySelectorAll('.stab[data-ctab]').forEach(b => b.classList.remove('active'));
document.getElementById(id)?.classList.remove('hidden');
document.querySelector(`.stab[data-ctab="${id}"]`)?.classList.add('active');
}
async function loadNOAACatalog() {
const tbody = document.getElementById('noaa-catalog-body');
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-muted)">Loading…</td></tr>';
try {
const r = await fetch(`${API}/charts/noaa-catalog`);
const cells = await r.json();
tbody.innerHTML = cells.map(c => `
<tr id="crow-${c.id}">
<td style="font-family:monospace;color:var(--accent)">${c.id}</td>
<td>${c.description}<br><span style="font-size:0.68rem;color:var(--text-muted)">1:${c.scale.toLocaleString()}</span></td>
<td>${c.installed
? '<span style="color:#4caf50">INSTALLED</span>'
: '<span style="color:var(--text-muted)">—</span>'}</td>
<td>
${c.installed
? `<button class="chart-row-btn danger" onclick="deleteCell('${c.id}')">REMOVE</button>`
: `<button class="chart-row-btn" onclick="downloadNOAA('${c.id}')">DOWNLOAD</button>`}
</td>
</tr>`).join('');
} catch(e) {
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--red)">Error loading catalog</td></tr>';
}
}
async function loadInstalledCells() {
const tbody = document.getElementById('installed-body');
const empty = document.getElementById('installed-empty');
empty.style.display = 'none';
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-muted)">Loading…</td></tr>';
try {
const r = await fetch(`${API}/charts/cells`);
if (!r.ok) throw new Error(`Server error ${r.status}`);
const cells = await r.json();
if (!Array.isArray(cells) || !cells.length) {
tbody.innerHTML = '';
empty.style.display = '';
return;
}
empty.style.display = 'none';
tbody.innerHTML = cells.map(c => `
<tr id="crow-${c.id}">
<td style="font-family:monospace;color:var(--accent)">${c.id}</td>
<td id="feat-${c.id}">${c.features} features</td>
<td>
<select class="form-input-select" style="padding:2px 6px;font-size:0.7rem"
onchange="setCellRegion('${c.id}', this.value)">
<option value="A" ${c.region === 'A' ? 'selected' : ''}>A — Europe/Asia (Red=Port)</option>
<option value="B" ${c.region === 'B' ? 'selected' : ''}>B — Americas/Japan (Green=Port)</option>
</select>
</td>
<td style="white-space:nowrap;display:flex;gap:4px">
<button class="chart-row-btn" id="rbtn-${c.id}" onclick="rebuildCell('${c.id}')">REBUILD</button>
<button class="chart-row-btn danger" onclick="deleteCell('${c.id}')">REMOVE</button>
</td>
</tr>`).join('');
} catch(e) {
tbody.innerHTML = `<tr><td colspan="4" style="color:var(--red)">Error: ${e.message}</td></tr>`;
}
}
// ── Rebuild a single chart cell (re-parses .000 → regenerates GeoJSON cache)
window.rebuildCell = async function(cellId) {
const btn = document.getElementById(`rbtn-${cellId}`);
const featEl = document.getElementById(`feat-${cellId}`);
const status = document.getElementById('chart-install-status');
if (btn) { btn.textContent = 'REBUILDING…'; btn.disabled = true; }
status.innerHTML = `<span style="color:var(--cyan)">Rebuilding ${cellId}…</span>`;
try {
const r = await fetch(`${API}/charts/cells/${cellId}/rebuild`, { method: 'POST' });
if (!r.ok) throw new Error((await r.json()).detail || `Error ${r.status}`);
status.innerHTML = `<span style="color:#4caf50">✓ ${cellId} rebuilt</span>`;
// Refresh feature count
const cells = await (await fetch(`${API}/charts/cells`)).json();
const updated = cells.find(c => c.id === cellId);
if (updated && featEl) featEl.textContent = `${updated.features} features`;
// Reload map layers
window.reloadAllChartLayers?.();
} catch(e) {
status.innerHTML = `<span style="color:var(--red)">✗ ${e.message}</span>`;
} finally {
if (btn) { btn.textContent = 'REBUILD'; btn.disabled = false; }
}
};
// ── Rebuild ALL chart cells
window.rebuildAllCells = async function() {
const btn = document.getElementById('btn-rebuild-all');
const status = document.getElementById('chart-install-status');
if (btn) { btn.textContent = 'REBUILDING ALL…'; btn.disabled = true; }
status.innerHTML = `<span style="color:var(--cyan)">Rebuilding all charts… (may take a moment)</span>`;
try {
const r = await fetch(`${API}/charts/rebuild-cache`, { method: 'POST' });
const data = await r.json();
if (!r.ok) throw new Error(data.detail || `Error ${r.status}`);
const n = data.rebuilt?.length || 0;
status.innerHTML = `<span style="color:#4caf50">✓ ${n} chart(s) rebuilt: ${data.rebuilt?.join(', ')}</span>`;
loadInstalledCells(); // refresh table
window.reloadAllChartLayers?.();
} catch(e) {
status.innerHTML = `<span style="color:var(--red)">✗ ${e.message}</span>`;
} finally {
if (btn) { btn.textContent = 'REBUILD ALL'; btn.disabled = false; }
}
};
window.setCellRegion = async function(cellId, region) {
try {
const r = await fetch(`${API}/charts/cells/${cellId}/region?region=${region}`, { method: 'PATCH' });
if (!r.ok) throw new Error((await r.json()).detail || 'Failed');
window.reloadAllChartLayers?.(); // re-render with new region colours
} catch(e) {
alert('Could not set region: ' + e.message);
}
};
window.downloadNOAA = async function(cellId) {
const btn = document.querySelector(`#crow-${cellId} .chart-row-btn`);
if (btn) { btn.textContent = 'DOWNLOADING…'; btn.disabled = true; }
const status = document.getElementById('chart-install-status');
status.innerHTML = `<span style="color:var(--cyan)">Downloading ${cellId} from NOAA…</span>`;
try {
const r = await fetch(`${API}/charts/download-noaa/${cellId}`, { method: 'POST' });
const data = await r.json();
if (!r.ok) throw new Error(data.detail || 'Download failed');
status.innerHTML = `<span style="color:#4caf50">✓ ${cellId} installed — ${data.installed?.join(', ')}</span>`;
loadNOAACatalog();
window.reloadAllChartLayers?.();
} catch(e) {
status.innerHTML = `<span style="color:var(--red)">✗ ${e.message}</span>`;
if (btn) { btn.textContent = 'DOWNLOAD'; btn.disabled = false; }
}
};
window.deleteCell = async function(cellId) {
if (!confirm(`Remove chart cell ${cellId}?`)) return;
const status = document.getElementById('chart-install-status');
await fetch(`${API}/charts/cells/${cellId}`, { method: 'DELETE' });
status.innerHTML = `<span style="color:var(--text-muted)">${cellId} removed</span>`;
loadNOAACatalog();
loadInstalledCells();
window.reloadAllChartLayers?.();
};
document.getElementById('btn-chart-upload')?.addEventListener('click', async () => {
const f = document.getElementById('chart-file').files[0];
const status = document.getElementById('chart-upload-status');
if (!f) { status.innerHTML = '<span style="color:var(--red)">Select a file first</span>'; return; }
status.innerHTML = `<span style="color:var(--cyan)">Uploading ${f.name}…</span>`;
const fd = new FormData();
fd.append('file', f);
try {
const r = await fetch(`${API}/charts/upload`, { method: 'POST', body: fd });
const data = await r.json();
if (!r.ok) throw new Error(data.detail);
status.innerHTML = `<span style="color:#4caf50">✓ Installed: ${data.installed.join(', ')}</span>`;
loadNOAACatalog();
window.reloadAllChartLayers?.();
} catch(e) {
status.innerHTML = `<span style="color:var(--red)">✗ ${e.message}</span>`;
}
});
// ── Cerrar modales ────────────────────────────────────────────────────────────
[
['btn-close-users', 'modal-users'],
['btn-close-users2', 'modal-users'],
['btn-close-charts', 'modal-charts'],
['btn-close-charts2', 'modal-charts'],
['btn-close-user-form','modal-user-form'],
['btn-uf-cancel', 'modal-user-form'],
].forEach(([btnId, modalId]) => {
document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId));
});
// ── Rebuild ENC symbol cache (footer button — delegates to rebuildAllCells) ───
// onclick="rebuildAllCells()" is already set in HTML; this listener is a no-op
// kept only so existing code that wires the button doesn't break.
document.getElementById('btn-rebuild-cache')?.addEventListener('click', () => {});
// ── Helpers modales ───────────────────────────────────────────────────────────
function _showModal(id) {
document.getElementById('modal-overlay').classList.remove('hidden');
document.getElementById(id).classList.remove('hidden');
}
function _hideModal(id) {
document.getElementById(id).classList.add('hidden');
const anyOpen = [...document.querySelectorAll('.modal')].some(m => !m.classList.contains('hidden'));
if (!anyOpen) document.getElementById('modal-overlay').classList.add('hidden');
}
window._showModal = _showModal;
window._hideModal = _hideModal;
// ── MODAL SETTINGS ────────────────────────────────────────────────────────
const SETTINGS_KEY = 'ams_settings';
function loadSettings() {
try { return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || {}; } catch { return {}; }
}
function saveSettings(data) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(data));
}
async function openSettingsModal(tab = 'ais') {
document.getElementById('settings-status').textContent = 'Loading…';
let s = {};
try {
const r = await fetch(`${API}/settings`, {
headers: { Authorization: `Bearer ${window.Auth?.token()}` },
});
if (r.ok) {
s = await r.json();
saveSettings(s); // keep localStorage in sync as cache
} else {
s = loadSettings(); // fallback to localStorage if not authenticated yet
}
} catch {
s = loadSettings();
}
document.getElementById('set-ais-source').value = s.ais_source || s.aisSource || 'SIMULATOR';
document.getElementById('set-serial-port').value = s.ais_serial_port || s.serialPort || '';
document.getElementById('set-baud').value = s.ais_baud || s.baud || '38400';
document.getElementById('set-net-addr').value = s.ais_net_addr || s.netAddr || '';
document.getElementById('set-station-name').value = s.station_name || s.stationName || '';
document.getElementById('set-iala-region').value = s.iala_region || s.ialaRegion || window.IALA_REGION || 'B';
document.getElementById('set-gps-port').value = s.gps_port || s.gpsPort || '';
document.getElementById('set-ant-lat').value = s.antenna_lat ?? s.antLat ?? '';
document.getElementById('set-ant-lon').value = s.antenna_lon ?? s.antLon ?? '';
document.getElementById('set-ant-height').value = s.antenna_height_m ?? s.antHeight ?? '';
document.getElementById('set-station-notes').value = s.station_notes || s.stationNotes || '';
document.getElementById('set-disp-warn').value = s.displacement_warn_m ?? s.dispWarn ?? 10;
document.getElementById('set-disp-alarm').value = s.displacement_alarm_m ?? s.dispAlarm ?? 15;
document.getElementById('set-prox-warn').value = s.proximity_alert_meters ?? s.proxWarn ?? 300;
document.getElementById('set-rec-trigger').value = s.auto_record_trigger_m ?? s.recTrigger ?? 200;
document.getElementById('set-batt-warn').value = s.battery_warn_v ?? s.battWarn ?? 11.5;
document.getElementById('set-batt-alarm').value = s.battery_alarm_v ?? s.battAlarm ?? 10.8;
document.getElementById('set-smtp-host').value = s.smtp_host || '';
document.getElementById('set-smtp-port').value = s.smtp_port || 587;
document.getElementById('set-smtp-user').value = s.smtp_user || '';
document.getElementById('set-smtp-password').value = s.smtp_password || '';
document.getElementById('set-smtp-from').value = s.smtp_from || '';
document.getElementById('set-smtp-from-name').value = s.smtp_from_name || '';
document.getElementById('set-smtp-tls').checked = s.smtp_use_tls !== false;
document.getElementById('settings-status').textContent = '';
_activateTab(tab);
_showModal('modal-settings');
}
function _activateTab(tab) {
document.querySelectorAll('.stab').forEach(b => {
b.classList.toggle('active', b.dataset.tab === tab);
});
document.querySelectorAll('.stab-panel').forEach(p => {
p.classList.toggle('hidden', p.id !== `stab-${tab}`);
});
}
document.querySelectorAll('.stab[data-tab]').forEach(btn => {
btn.addEventListener('click', () => _activateTab(btn.dataset.tab));
});
// ── EQUIPMENT SCANNER ─────────────────────────────────────────────────────────
const DEVICE_ICONS = {
GPS: '🛰', AIS: '📡', ATON: '🔆', GYRO: '🧭',
RADAR: '📻', SONAR: '🌊', WIND: '💨', AUTOPILOT: '🎛',
'IN USE': '🔒', 'NO DATA': '—', UNKNOWN: '❓',
};
const DEVICE_COLOR = {
GPS:'var(--green)', AIS:'var(--cyan)', ATON:'var(--yellow)',
GYRO:'var(--cyan)', RADAR:'#f97316', SONAR:'#06b6d4',
WIND:'#a78bfa', AUTOPILOT:'#f472b6',
'IN USE':'var(--text-muted)', 'NO DATA':'var(--text-muted)', UNKNOWN:'var(--yellow)',
};
document.getElementById('btn-scan-ports')?.addEventListener('click', async () => {
const btn = document.getElementById('btn-scan-ports');
const status = document.getElementById('scan-status');
const list = document.getElementById('equipment-list');
btn.disabled = true;
btn.textContent = 'SCANNING…';
status.textContent = 'Reading serial ports — this may take 1530 s…';
list.innerHTML = '';
try {
const r = await fetch(`${API}/equipment/scan`);
const devs = await r.json();
if (!devs.length) {
list.innerHTML = '<div style="color:var(--text-muted);font-size:0.78rem">No serial ports detected.</div>';
} else {
list.innerHTML = devs.map(d => {
const icon = DEVICE_ICONS[d.device_type] || '?';
const color = DEVICE_COLOR[d.device_type] || 'var(--text-primary)';
const sentences = (d.sentences || []).map(s =>
`<div class="eq-sentence">${s}</div>`).join('');
return `
<div class="eq-card">
<div class="eq-card-header">
<span class="eq-type" style="color:${color}">${icon} ${d.device_type}</span>
<span class="eq-port">${d.port}${d.baud ? ' @ ' + d.baud + ' baud' : ''}</span>
</div>
<div class="eq-desc">${d.description}</div>
<div class="eq-hwid">${d.port_desc || ''}</div>
${sentences ? `<div class="eq-sentences">${sentences}</div>` : ''}
</div>`;
}).join('');
}
status.textContent = `${devs.length} port(s) scanned.`;
} catch(e) {
status.textContent = 'Scan failed: ' + e.message;
} finally {
btn.disabled = false;
btn.textContent = 'SCAN PORTS';
}
});
document.getElementById('btn-settings-save')?.addEventListener('click', async () => {
const s = {
aisSource: document.getElementById('set-ais-source').value,
serialPort: document.getElementById('set-serial-port').value.trim(),
baud: document.getElementById('set-baud').value,
netAddr: document.getElementById('set-net-addr').value.trim(),
ialaRegion: document.getElementById('set-iala-region').value,
gpsPort: document.getElementById('set-gps-port').value.trim(),
stationName: document.getElementById('set-station-name').value.trim(),
antLat: parseFloat(document.getElementById('set-ant-lat').value) || null,
antLon: parseFloat(document.getElementById('set-ant-lon').value) || null,
antHeight: parseFloat(document.getElementById('set-ant-height').value) || null,
stationNotes: document.getElementById('set-station-notes').value.trim(),
dispWarn: parseFloat(document.getElementById('set-disp-warn').value),
dispAlarm: parseFloat(document.getElementById('set-disp-alarm').value),
proxWarn: parseFloat(document.getElementById('set-prox-warn').value),
recTrigger: parseFloat(document.getElementById('set-rec-trigger').value),
battWarn: parseFloat(document.getElementById('set-batt-warn').value),
battAlarm: parseFloat(document.getElementById('set-batt-alarm').value),
};
saveSettings(s);
updateStatusBar();
// Apply IALA region live for non-S57 aids (S-57 cells have their own per-cell region)
if (window.IALA_REGION !== s.ialaRegion) {
window.IALA_REGION = s.ialaRegion;
localStorage.setItem('ams_iala', s.ialaRegion);
if (window._iconCache) Object.keys(window._iconCache).forEach(k => { if (k.startsWith('boya_lat')) delete window._iconCache[k]; });
if (window.aidsSource) window.aidsSource.changed();
}
// Push to backend so alert thresholds, GPS, AIS source, etc. apply live
const st = document.getElementById('settings-status');
st.textContent = 'Saving…';
st.className = 'save-status';
try {
const res = await fetch(`${API}/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json',
'Authorization': `Bearer ${window.Auth?.token()}` },
body: JSON.stringify({
ais_source: s.aisSource,
ais_serial_port: s.serialPort,
ais_baud: parseInt(s.baud) || 38400,
ais_net_addr: s.netAddr,
gps_port: s.gpsPort,
gps_baud: 9600,
station_name: s.stationName,
iala_region: s.ialaRegion,
antenna_lat: s.antLat,
antenna_lon: s.antLon,
antenna_height_m: s.antHeight,
station_notes: s.stationNotes,
displacement_warn_m: s.dispWarn,
displacement_alarm_m: s.dispAlarm,
proximity_alert_meters: s.proxWarn,
auto_record_trigger_m: s.recTrigger,
battery_warn_v: s.battWarn,
battery_alarm_v: s.battAlarm,
smtp_host: document.getElementById('set-smtp-host').value.trim(),
smtp_port: parseInt(document.getElementById('set-smtp-port').value) || 587,
smtp_user: document.getElementById('set-smtp-user').value.trim(),
smtp_password: document.getElementById('set-smtp-password').value,
smtp_from: document.getElementById('set-smtp-from').value.trim(),
smtp_from_name: document.getElementById('set-smtp-from-name').value.trim(),
smtp_use_tls: document.getElementById('set-smtp-tls').checked,
}),
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.detail || 'Failed');
const extra = data.applied?.length ? ` (${data.applied.join(', ')})` : '';
st.textContent = 'Settings saved.' + extra;
st.className = 'save-status ok';
} catch (e) {
st.textContent = 'Saved locally — backend error: ' + e.message;
st.className = 'save-status error';
}
setTimeout(() => { st.textContent = ''; }, 4000);
});
[
['btn-close-settings', 'modal-settings'],
['btn-close-settings2', 'modal-settings'],
].forEach(([btnId, modalId]) => {
document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId));
});
// ── MODAL RECORDINGS ──────────────────────────────────────────────────────
function openRecordingsModal() {
const today = new Date().toISOString().slice(0, 10);
document.getElementById('rec-filter-to').value = today;
const from = new Date(Date.now() - 7 * 86400000).toISOString().slice(0, 10);
document.getElementById('rec-filter-from').value = from;
document.getElementById('modal-recordings-body').innerHTML =
'<div style="color:var(--text-muted);font-size:0.75rem;padding:20px 0;text-align:center">Press SEARCH to load recordings.</div>';
_showModal('modal-recordings');
}
document.getElementById('btn-rec-search')?.addEventListener('click', async () => {
const body = document.getElementById('modal-recordings-body');
body.innerHTML = '<div style="color:var(--text-muted);padding:8px">Loading...</div>';
try {
const from = document.getElementById('rec-filter-from').value;
const to = document.getElementById('rec-filter-to').value;
const mmsi = document.getElementById('rec-filter-mmsi').value.trim();
let url = `${API}/recordings?from=${from}&to=${to}`;
if (mmsi) url += `&mmsi=${encodeURIComponent(mmsi)}`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${window.Auth?.token()}` } });
if (!res.ok) throw new Error(await res.text());
const recs = await res.json();
if (!recs.length) {
body.innerHTML = '<div style="color:var(--text-muted);font-size:0.75rem;padding:20px 0;text-align:center">No recordings found for the selected period.</div>';
return;
}
body.innerHTML = `
<table class="users-table">
<thead><tr>
<th>VESSEL</th><th>MMSI</th><th>AID</th>
<th>START (UTC)</th><th>END (UTC)</th><th>MIN DIST</th>
</tr></thead>
<tbody>${recs.map(r => `
<tr>
<td>${r.vessel_nombre || '--'}</td>
<td class="mono">${r.mmsi}</td>
<td>${r.aid_nombre || '--'}</td>
<td class="mono" style="font-size:0.68rem">${r.inicio_utc ? new Date(r.inicio_utc).toUTCString().slice(0,25) : '--'}</td>
<td class="mono" style="font-size:0.68rem">${r.fin_utc ? new Date(r.fin_utc).toUTCString().slice(0,25) : 'ACTIVE'}</td>
<td>${r.distancia_min_m != null ? r.distancia_min_m + ' m' : '--'}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch (e) {
body.innerHTML = `<div class="modal-error" style="display:block">Error: ${e.message}</div>`;
}
});
[
['btn-close-recordings', 'modal-recordings'],
['btn-close-recordings2', 'modal-recordings'],
].forEach(([btnId, modalId]) => {
document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId));
});
// ── MODAL LAMP CATALOG ────────────────────────────────────────────────────
window._lampCache = []; // exposed so the right panel can populate its dropdown
function _lampThresholds(vmin, vmax, warn_pct, alarm_pct) {
const rng = vmax - vmin;
const wp = ((warn_pct ?? 20) / 100);
const ap = ((alarm_pct ?? 10) / 100);
return { warn: +(vmin + rng * wp).toFixed(3), alarm: +(vmin + rng * ap).toFixed(3) };
}
async function loadLamps() {
try {
const r = await fetch(`${API}/lamps/`);
window._lampCache = await r.json();
} catch { window._lampCache = []; }
return window._lampCache;
}
window.loadLamps = loadLamps;
async function openLampsModal() {
await loadLamps();
renderLampsTable();
document.getElementById('lamps-status').textContent = '';
_showModal('modal-lamps');
}
function renderLampsTable() {
const tbody = document.getElementById('lamps-body');
tbody.innerHTML = window._lampCache.map(l => `
<tr data-id="${l.id}">
<td>${l.manufacturer}</td>
<td>${l.model}</td>
<td>${l.lamp_count}</td>
<td>${l.voltage_min}</td>
<td>${l.voltage_max}</td>
<td style="color:var(--yellow)">${l.warn_v} V</td>
<td style="color:var(--red)">${l.alarm_v} V</td>
<td style="font-size:0.7rem;color:var(--text-muted)">${l.notes || ''}</td>
<td style="display:flex;gap:4px">
<button class="chart-row-btn" onclick="editLamp('${l.id}')">EDIT</button>
<button class="chart-row-btn danger" onclick="deleteLamp('${l.id}')">DEL</button>
</td>
</tr>`).join('');
}
window.editLamp = function(id) {
const l = window._lampCache.find(x => x.id === id);
if (!l) return;
const tr = document.querySelector(`#lamps-body tr[data-id="${id}"]`);
if (!tr) return;
tr.innerHTML = `
<td><input class="form-input" id="lp-edit-mfr" value="${l.manufacturer}"></td>
<td><input class="form-input" id="lp-edit-model" value="${l.model}"></td>
<td><input class="form-input" id="lp-edit-count" type="number" value="${l.lamp_count}" style="width:60px"></td>
<td><input class="form-input" id="lp-edit-vmin" type="number" step="0.1" value="${l.voltage_min}" style="width:70px"></td>
<td><input class="form-input" id="lp-edit-vmax" type="number" step="0.1" value="${l.voltage_max}" style="width:70px"></td>
<td><input class="form-input" id="lp-edit-wpct" type="number" step="1" min="1" max="50" value="${l.warn_pct ?? 20}" style="width:55px" title="Warn %"></td>
<td><input class="form-input" id="lp-edit-apct" type="number" step="1" min="1" max="50" value="${l.alarm_pct ?? 10}" style="width:55px" title="Alarm %"></td>
<td style="font-size:0.7rem" id="lp-edit-preview">${l.warn_v} V / ${l.alarm_v} V</td>
<td><input class="form-input" id="lp-edit-notes" value="${l.notes || ''}"></td>
<td style="display:flex;gap:4px">
<button class="chart-row-btn" onclick="saveLamp('${id}')">SAVE</button>
<button class="chart-row-btn danger" onclick="renderLampsTable()">CANCEL</button>
</td>`;
// Live preview while editing vmin/vmax
['lp-edit-vmin','lp-edit-vmax','lp-edit-wpct','lp-edit-apct'].forEach(i =>
document.getElementById(i).addEventListener('input', () => {
const vmin = parseFloat(document.getElementById('lp-edit-vmin').value);
const vmax = parseFloat(document.getElementById('lp-edit-vmax').value);
const wpct = parseFloat(document.getElementById('lp-edit-wpct').value);
const apct = parseFloat(document.getElementById('lp-edit-apct').value);
const cell = document.getElementById('lp-edit-preview');
if (isNaN(vmin) || isNaN(vmax) || vmax <= vmin) { cell.textContent = '—'; return; }
const t = _lampThresholds(vmin, vmax, wpct, apct);
cell.innerHTML = `<span style="color:var(--yellow)">${t.warn} V</span> / <span style="color:var(--red)">${t.alarm} V</span>`;
}));
};
window.saveLamp = async function(id) {
const status = document.getElementById('lamps-status');
const payload = {
manufacturer: document.getElementById('lp-edit-mfr').value.trim(),
model: document.getElementById('lp-edit-model').value.trim(),
lamp_count: parseInt(document.getElementById('lp-edit-count').value) || 1,
voltage_min: parseFloat(document.getElementById('lp-edit-vmin').value),
voltage_max: parseFloat(document.getElementById('lp-edit-vmax').value),
warn_pct: parseFloat(document.getElementById('lp-edit-wpct').value) || 20.0,
alarm_pct: parseFloat(document.getElementById('lp-edit-apct').value) || 10.0,
notes: document.getElementById('lp-edit-notes').value.trim() || null,
};
if (!payload.manufacturer || !payload.model || isNaN(payload.voltage_min) || isNaN(payload.voltage_max)) {
status.textContent = 'Manufacturer, model, V min and V max are required.';
status.className = 'save-status error'; return;
}
try {
const r = await fetch(`${API}/lamps/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Failed');
await loadLamps(); renderLampsTable();
status.textContent = `Updated ${d.manufacturer} ${d.model}`;
status.className = 'save-status ok';
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status error';
}
};
// Live preview of warn/alarm as the user types vmin/vmax in the new-row inputs
function _updateLampPreview() {
const vmin = parseFloat(document.getElementById('lp-new-vmin').value);
const vmax = parseFloat(document.getElementById('lp-new-vmax').value);
const cell = document.getElementById('lp-new-preview');
if (isNaN(vmin) || isNaN(vmax) || vmax <= vmin) { cell.textContent = '—'; return; }
const t = _lampThresholds(vmin, vmax);
cell.innerHTML = `<span style="color:var(--yellow)">${t.warn} V</span> / <span style="color:var(--red)">${t.alarm} V</span>`;
}
['lp-new-vmin', 'lp-new-vmax'].forEach(id => {
document.getElementById(id)?.addEventListener('input', _updateLampPreview);
});
document.getElementById('btn-lamp-add')?.addEventListener('click', async () => {
const status = document.getElementById('lamps-status');
const payload = {
manufacturer: document.getElementById('lp-new-mfr').value.trim(),
model: document.getElementById('lp-new-model').value.trim(),
lamp_count: parseInt(document.getElementById('lp-new-count').value) || 1,
voltage_min: parseFloat(document.getElementById('lp-new-vmin').value),
voltage_max: parseFloat(document.getElementById('lp-new-vmax').value),
notes: document.getElementById('lp-new-notes').value.trim() || null,
};
if (!payload.manufacturer || !payload.model || isNaN(payload.voltage_min) || isNaN(payload.voltage_max)) {
status.textContent = 'Manufacturer, model, V min and V max are required.';
status.className = 'save-status error'; return;
}
try {
const r = await fetch(`${API}/lamps/`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Failed');
// Reset row + refresh
['lp-new-mfr','lp-new-model','lp-new-vmin','lp-new-vmax','lp-new-notes'].forEach(i => document.getElementById(i).value = '');
document.getElementById('lp-new-count').value = '1';
document.getElementById('lp-new-preview').textContent = '—';
await loadLamps(); renderLampsTable();
status.textContent = `Added ${d.manufacturer} ${d.model}`;
status.className = 'save-status ok';
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status error';
}
});
window.deleteLamp = async function(id) {
if (!confirm('Delete this lamp from the catalog?')) return;
const status = document.getElementById('lamps-status');
try {
let r = await fetch(`${API}/lamps/${id}`, { method: 'DELETE' });
if (r.status === 409) {
// Lamp is in use — ask user if they want to unassign and delete anyway
const d = await r.json();
const ok = confirm(`${d.detail}.\n\nUnassign from those aids and delete anyway?`);
if (!ok) return;
r = await fetch(`${API}/lamps/${id}?force=true`, { method: 'DELETE' });
}
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Failed');
await loadLamps(); renderLampsTable();
status.textContent = d.unassigned_from
? `Deleted (unassigned from ${d.unassigned_from} aid(s)).`
: 'Deleted.';
status.className = 'save-status ok';
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status error';
}
};
[
['btn-close-lamps', 'modal-lamps'],
['btn-close-lamps2', 'modal-lamps'],
].forEach(([btnId, modalId]) => {
document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId));
});
// Pre-load lamps once at startup so the right panel dropdown is ready immediately.
loadLamps();
// ── MODAL CONTACTS CATALOG ────────────────────────────────────────────────
window._contactsCache = [];
async function loadContacts() {
try {
const r = await fetch(`${API}/contacts/`, {
headers: { Authorization: `Bearer ${window.Auth?.token()}` },
});
window._contactsCache = await r.json();
} catch { window._contactsCache = []; }
return window._contactsCache;
}
window.loadContacts = loadContacts;
window.openContactsModal = async function openContactsModal() {
await loadContacts();
renderContactsTable();
document.getElementById('contacts-status').textContent = '';
_showModal('modal-contacts');
}
function renderContactsTable() {
const tbody = document.getElementById('contacts-body');
tbody.innerHTML = window._contactsCache.map(c => {
const match = c.role === 'PORT_AUTHORITY' ? c.port_name : c.company_name;
const roleColor = c.role === 'PORT_AUTHORITY' ? 'var(--accent)' : 'var(--cyan)';
return `
<tr data-id="${c.id}">
<td><span style="color:${roleColor};font-size:0.7rem">${c.role === 'PORT_AUTHORITY' ? 'PORT' : 'OWNER'}</span></td>
<td>${c.name}</td>
<td>${match || '<span style="color:var(--red)">—</span>'}</td>
<td style="font-size:0.7rem">${c.email || '—'}</td>
<td style="font-size:0.7rem">${c.phone || '—'}</td>
<td style="font-size:0.7rem">${c.whatsapp || '—'}</td>
<td style="font-size:0.7rem">${c.preferred_channel}</td>
<td style="display:flex;gap:4px">
<button class="chart-row-btn" onclick="editContact('${c.id}')">EDIT</button>
<button class="chart-row-btn danger" onclick="deleteContact('${c.id}')">DEL</button>
</td>
</tr>`;
}).join('');
}
window.editContact = function(id) {
const c = window._contactsCache.find(x => x.id === id);
if (!c) return;
const tr = document.querySelector(`#contacts-body tr[data-id="${id}"]`);
if (!tr) return;
const match = c.role === 'PORT_AUTHORITY' ? c.port_name : c.company_name;
tr.innerHTML = `
<td>
<select class="form-input-select" id="ct-edit-role">
<option value="PORT_AUTHORITY" ${c.role === 'PORT_AUTHORITY' ? 'selected' : ''}>PORT AUTH</option>
<option value="OWNER" ${c.role === 'OWNER' ? 'selected' : ''}>OWNER</option>
</select>
</td>
<td><input class="form-input" id="ct-edit-name" value="${c.name}"></td>
<td><input class="form-input" id="ct-edit-match" value="${match || ''}"></td>
<td><input class="form-input" id="ct-edit-email" value="${c.email || ''}"></td>
<td><input class="form-input" id="ct-edit-phone" value="${c.phone || ''}"></td>
<td><input class="form-input" id="ct-edit-whatsapp" value="${c.whatsapp || ''}"></td>
<td>
<select class="form-input-select" id="ct-edit-pref">
<option value="EMAIL" ${c.preferred_channel === 'EMAIL' ? 'selected' : ''}>EMAIL</option>
<option value="WHATSAPP" ${c.preferred_channel === 'WHATSAPP' ? 'selected' : ''}>WHATSAPP</option>
<option value="SMS" ${c.preferred_channel === 'SMS' ? 'selected' : ''}>SMS</option>
</select>
</td>
<td style="display:flex;gap:4px">
<button class="chart-row-btn" onclick="saveContact('${id}')">SAVE</button>
<button class="chart-row-btn danger" onclick="renderContactsTable()">CANCEL</button>
</td>`;
};
window.saveContact = async function(id) {
const status = document.getElementById('contacts-status');
const role = document.getElementById('ct-edit-role').value;
const name = document.getElementById('ct-edit-name').value.trim();
const match = document.getElementById('ct-edit-match').value.trim();
const email = document.getElementById('ct-edit-email').value.trim();
const phone = document.getElementById('ct-edit-phone').value.trim();
const whatsapp = document.getElementById('ct-edit-whatsapp').value.trim();
const pref = document.getElementById('ct-edit-pref').value;
if (!name || !match) {
status.textContent = 'Name and Port/Company are required.';
status.className = 'save-status error'; return;
}
const payload = {
role, name,
email: email || null,
phone: phone || null,
whatsapp: whatsapp || null,
preferred_channel: pref,
port_name: role === 'PORT_AUTHORITY' ? match : null,
company_name: role === 'OWNER' ? match : null,
};
try {
const r = await fetch(`${API}/contacts/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.Auth?.token()}`,
},
body: JSON.stringify(payload),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Failed');
await loadContacts(); renderContactsTable();
status.textContent = `Updated ${d.name}`;
status.className = 'save-status ok';
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status error';
}
};
document.getElementById('btn-contact-add')?.addEventListener('click', async () => {
const status = document.getElementById('contacts-status');
const role = document.getElementById('ct-new-role').value;
const name = document.getElementById('ct-new-name').value.trim();
const match = document.getElementById('ct-new-match').value.trim();
const email = document.getElementById('ct-new-email').value.trim();
const phone = document.getElementById('ct-new-phone').value.trim();
const whatsapp = document.getElementById('ct-new-whatsapp').value.trim();
const pref = document.getElementById('ct-new-pref').value;
if (!name || !match) {
status.textContent = 'Name and Port/Company are required.';
status.className = 'save-status error'; return;
}
const payload = {
role, name,
email: email || null,
phone: phone || null,
whatsapp: whatsapp || null,
preferred_channel: pref,
port_name: role === 'PORT_AUTHORITY' ? match : null,
company_name: role === 'OWNER' ? match : null,
};
try {
const r = await fetch(`${API}/contacts/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.Auth?.token()}`,
},
body: JSON.stringify(payload),
});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'Failed');
['ct-new-name','ct-new-match','ct-new-email','ct-new-phone','ct-new-whatsapp']
.forEach(i => document.getElementById(i).value = '');
await loadContacts(); renderContactsTable();
status.textContent = `Added ${d.name}`;
status.className = 'save-status ok';
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status error';
}
});
window.deleteContact = async function(id) {
if (!confirm('Delete this contact?')) return;
const status = document.getElementById('contacts-status');
try {
const r = await fetch(`${API}/contacts/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${window.Auth?.token()}` },
});
if (!r.ok) throw new Error((await r.json()).detail);
await loadContacts(); renderContactsTable();
status.textContent = 'Deleted.'; status.className = 'save-status ok';
} catch (e) {
status.textContent = 'Error: ' + e.message;
status.className = 'save-status error';
}
};
[
['btn-close-contacts', 'modal-contacts'],
['btn-close-contacts2', 'modal-contacts'],
].forEach(([btnId, modalId]) => {
document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId));
});
// ── MODAL REPORT ALERT ────────────────────────────────────────────────────
// Opened from each event card's REPORT button. Resolves recipients via the
// aid metadata (port authority + owner), lets the operator edit the message,
// then opens the chosen channel (mailto / wa.me / sms) and logs the report.
const _CHANNEL_LABEL = { EMAIL: 'EMAIL', SMS: 'SMS', WHATSAPP: 'WHATSAPP' };
window.openReportModal = async function(itemEl) {
let alertObj;
try { alertObj = JSON.parse(itemEl.dataset.alertJson); }
catch { window.alert('Invalid alert data'); return; }
document.getElementById('modal-report').dataset.alertId = itemEl.dataset.alertId;
document.getElementById('modal-report').dataset.alertJson = itemEl.dataset.alertJson;
const summary = document.getElementById('report-alert-summary');
summary.innerHTML = `
<strong>${alertObj.tipo || 'ALERT'}</strong>
${alertObj.subtipo ? ` — ${alertObj.subtipo}` : ''}<br>
Aid: ${alertObj.aid_nombre || alertObj.aid_id || '—'}
${alertObj.mmsi ? ` &middot; MMSI ${alertObj.mmsi}` : ''}<br>
<span style="color:var(--text-muted)">${alertObj.timestamp || new Date().toISOString()}</span>
`;
const recDiv = document.getElementById('report-recipients');
recDiv.innerHTML = '<div style="color:var(--text-muted);font-size:0.78rem">Loading…</div>';
document.getElementById('report-status').textContent = '';
document.getElementById('report-message').value = 'Loading…';
// Fetch full aid info + recipients in parallel
const params = new URLSearchParams();
if (alertObj.aid_id) params.set('aid_id', alertObj.aid_id);
if (alertObj.mmsi) params.set('mmsi', alertObj.mmsi);
let aidFull = null;
try {
if (alertObj.aid_id) {
const r = await fetch(`${API}/aids/${alertObj.aid_id}`, {
headers: { Authorization: `Bearer ${window.Auth?.token()}` },
});
if (r.ok) aidFull = await r.json();
}
} catch {}
let recipientsResp;
try {
const r = await fetch(`${API}/alerts/resolve-recipients?${params.toString()}`);
recipientsResp = await r.json();
} catch (e) {
recipientsResp = { recipients: [] };
}
// Live ATON telemetry (voltage, last update, etc.)
const aton = window.atonData?.[alertObj.mmsi || aidFull?.mmsi] || {};
// Build a detailed default message from every source we have
document.getElementById('report-message').value = _buildReportMessage(alertObj, aidFull, aton, recipientsResp.aid);
// Split recipients by role so each section is explicit, and so the user
// sees clearly when a role is unmatched (no contact for that port/owner).
const portAuths = (recipientsResp.recipients || []).filter(c => c.role === 'PORT_AUTHORITY');
const owners = (recipientsResp.recipients || []).filter(c => c.role === 'OWNER');
const aidInfo = recipientsResp.aid || {};
const sendAllBtn = recipientsResp.recipients?.length
? `<button class="chart-row-btn ch-all" style="margin-bottom:10px" onclick="sendReportToAllRecipients()">⚡ SEND TO SELECTED — uncheck below to skip a recipient</button>`
: '';
recDiv.innerHTML = `
${sendAllBtn}
<div class="modal-section-label">PORT AUTHORITY${aidInfo.puerto_responsable ? ` — "${aidInfo.puerto_responsable}"` : ''}</div>
${portAuths.length
? portAuths.map(c => _renderRecipientRow(c)).join('')
: `<div style="color:var(--yellow);font-size:0.72rem;padding:6px 10px;background:#1a1500;border-radius:3px;margin-bottom:8px">
${aidInfo.puerto_responsable
? `No PORT_AUTHORITY contact configured for port "${aidInfo.puerto_responsable}". Add one in SETTINGS → Contacts.`
: 'This aid has no puerto_responsable assigned. Edit the aid to set it.'}
</div>`}
<div class="modal-section-label" style="margin-top:8px">OWNER${aidInfo.empresa_responsable ? ` — "${aidInfo.empresa_responsable}"` : ''}</div>
${owners.length
? owners.map(c => _renderRecipientRow(c)).join('')
: `<div style="color:var(--yellow);font-size:0.72rem;padding:6px 10px;background:#1a1500;border-radius:3px">
${aidInfo.empresa_responsable
? `No OWNER contact configured for company "${aidInfo.empresa_responsable}". Add one in SETTINGS → Contacts.`
: 'This aid has no empresa_responsable assigned. Edit the aid to set it.'}
</div>`}
`;
_showModal('modal-report');
};
function _buildReportMessage(alertObj, aid, aton, aidLite) {
const lines = [];
lines.push(`╔══ ALERTA ${alertObj.tipo || ''}${alertObj.subtipo ? ' — ' + alertObj.subtipo : ''} ══╗`);
lines.push(`Hora: ${alertObj.timestamp || new Date().toISOString()}`);
lines.push('');
// Aid identification
const a = aid || {};
lines.push('AYUDA A LA NAVEGACIÓN');
lines.push(` Nombre: ${a.nombre || alertObj.aid_nombre || '—'}`);
if (a.mmsi || alertObj.mmsi) lines.push(` MMSI: ${a.mmsi || alertObj.mmsi}`);
if (a.tipo) lines.push(` Tipo: ${a.tipo} (${a.categoria || '—'})`);
if (a.tipo_ais) lines.push(` AIS: ${a.tipo_ais}`);
// Positions
lines.push('');
lines.push('POSICIÓN');
if (a.lat_nominal != null && a.lon_nominal != null)
lines.push(` Oficial S-57: ${a.lat_nominal.toFixed(5)}, ${a.lon_nominal.toFixed(5)}`);
if (a.lat_actual != null && a.lon_actual != null)
lines.push(` Actual AIS: ${a.lat_actual.toFixed(5)}, ${a.lon_actual.toFixed(5)}`);
if (a.desplazamiento_m != null)
lines.push(` Desplazamiento: ${a.desplazamiento_m.toFixed(1)} m`);
else if (alertObj.desplazamiento_m != null)
lines.push(` Desplazamiento: ${alertObj.desplazamiento_m} m`);
if (a.radio_borneo_m) lines.push(` Radio borneo: ${a.radio_borneo_m} m`);
// Light characteristics
if (a.caracteristica_luz || a.alcance_nm) {
lines.push('');
lines.push('CARACTERÍSTICAS LUMINOSAS');
if (a.caracteristica_luz) lines.push(` Luz: ${a.caracteristica_luz}`);
if (a.alcance_nm) lines.push(` Rango: ${a.alcance_nm} NM`);
}
// Lamp + battery
if (a.lamp || aton.voltage_v != null || alertObj.voltage_v != null) {
lines.push('');
lines.push('LÁMPARA Y BATERÍA');
if (a.lamp) {
lines.push(` Modelo: ${a.lamp.manufacturer} ${a.lamp.model} (×${a.lamp.lamp_count})`);
lines.push(` Vmin/Vmax nominal: ${a.lamp.voltage_min} V / ${a.lamp.voltage_max} V`);
lines.push(` Umbral WARN: ${a.lamp.warn_v} V · ALARM: ${a.lamp.alarm_v} V`);
}
const v = aton.voltage_v ?? alertObj.voltage_v;
if (v != null) {
lines.push(` Voltaje actual: ${v} V` + (aton.last_update ? ` (${aton.last_update.slice(11,19)} UTC)` : ''));
}
if (aton.battery_low) lines.push(` ⚠ Battery_low flag activo`);
if (aton.light_ok === false) lines.push(` ⚠ LIGHT FAULT reportado por AIS`);
}
// Responsibility
lines.push('');
lines.push('RESPONSABLES');
lines.push(` Puerto: ${a.puerto_responsable || '(no asignado)'}`);
lines.push(` Empresa: ${a.empresa_responsable || '(no asignada)'}`);
if (a.observaciones) {
lines.push('');
lines.push('OBSERVACIONES');
lines.push(` ${a.observaciones}`);
}
lines.push('');
lines.push('Por favor verifique en sitio y confirme.');
lines.push(`Reportado por: ${window.Auth?.session?.nombre || 'operator'}`);
return lines.join('\n');
}
function _renderRecipientRow(c) {
const safeName = c.name.replace(/'/g, "\\'");
const channels = [];
const has = (c.email ? 1 : 0) + (c.whatsapp ? 1 : 0) + (c.phone ? 1 : 0);
if (c.email) channels.push(`<button class="chart-row-btn ch-email" onclick="sendReport('${c.id}','${safeName}','EMAIL','${c.email}')">✉ EMAIL</button>`);
if (c.whatsapp) channels.push(`<button class="chart-row-btn ch-whatsapp" onclick="sendReport('${c.id}','${safeName}','WHATSAPP','${c.whatsapp}')">🟢 WHATSAPP</button>`);
if (c.phone) channels.push(`<button class="chart-row-btn ch-sms" onclick="sendReport('${c.id}','${safeName}','SMS','${c.phone}')">☎ SMS</button>`);
if (has > 1) channels.push(`<button class="chart-row-btn ch-all" onclick="sendReportAll('${c.id}','${safeName}','${c.email||''}','${c.whatsapp||''}','${c.phone||''}')">⚡ ALL</button>`);
const roleLabel = c.role === 'PORT_AUTHORITY' ? 'PORT AUTHORITY' : 'OWNER';
const roleColor = c.role === 'PORT_AUTHORITY' ? 'var(--accent)' : 'var(--cyan)';
return `
<div class="report-recipient" data-contact-id="${c.id}" style="background:#0e1a2a;border:1px solid #1f3550;border-radius:3px;padding:8px 10px;margin-bottom:6px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" class="rcp-check" checked
data-contact-id="${c.id}"
data-contact-name="${safeName}"
data-email="${c.email || ''}"
data-whatsapp="${c.whatsapp || ''}"
data-phone="${c.phone || ''}"
data-preferred="${c.preferred_channel}"
style="width:14px;height:14px;cursor:pointer">
<span><strong>${c.name}</strong> <span style="color:${roleColor};font-size:0.7rem">(${roleLabel})</span></span>
</label>
<span style="color:var(--text-muted);font-size:0.7rem">preferred: ${_CHANNEL_LABEL[c.preferred_channel] || c.preferred_channel}</span>
</div>
<div style="font-size:0.72rem;color:var(--text-muted);margin-bottom:6px;padding-left:22px">
${c.email ? '✉ '+c.email+' ' : ''}${c.phone ? '☎ '+c.phone+' ' : ''}${c.whatsapp ? '🟢 '+c.whatsapp : ''}
</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;padding-left:22px">${channels.join('') || '<span style="color:var(--text-muted);font-size:0.7rem">No channels configured</span>'}</div>
</div>`;
}
window.sendReportToAllRecipients = function() {
// Iterate the CURRENTLY-CHECKED recipient checkboxes synchronously so all
// channel-opens happen inside the user-gesture tick (popup blocker happy).
const modal = document.getElementById('modal-report');
const alertObj = JSON.parse(modal.dataset.alertJson);
const message = document.getElementById('report-message').value;
const subject = `[AidsMonitoring] ${alertObj.tipo || 'Alert'}${alertObj.aid_nombre || alertObj.aid_id || ''}`;
const fires = [];
document.querySelectorAll('.rcp-check:checked').forEach(cb => {
const ch = cb.dataset.preferred;
const email = cb.dataset.email;
const whatsapp = cb.dataset.whatsapp;
const phone = cb.dataset.phone;
let recipient = null, channel = ch;
if (ch === 'EMAIL') recipient = email;
else if (ch === 'WHATSAPP') recipient = whatsapp;
else if (ch === 'SMS') recipient = phone;
if (!recipient) { // fallback to whatever is set
if (email) { recipient = email; channel = 'EMAIL'; }
else if (whatsapp) { recipient = whatsapp; channel = 'WHATSAPP'; }
else if (phone) { recipient = phone; channel = 'SMS'; }
}
if (recipient) fires.push({
contactId: cb.dataset.contactId,
contactName: cb.dataset.contactName,
channel, recipient,
});
});
if (!fires.length) {
document.getElementById('report-status').textContent = 'No recipients selected.';
document.getElementById('report-status').className = 'save-status error';
return;
}
// Open every channel first (single tick), then log them async.
// Also collect the URLs so we can offer manual click-through fallbacks
// if Chrome's popup blocker swallowed one.
const fallbacks = [];
for (const f of fires) {
const url = _openChannel(f.channel, f.recipient, subject, message);
if (url) fallbacks.push({ ...f, url });
}
for (const f of fires) _logAndMarkReport(f.contactId, f.contactName, f.channel, f.recipient, alertObj, message);
// Render fallback links so the operator can manually click any blocked one
const status = document.getElementById('report-status');
status.className = 'save-status ok';
status.innerHTML = `Tried to open ${fires.length} channel(s).
If some didn't open (Chrome popup blocker), click here:<br>
${fallbacks.map(f =>
`<a href="${f.url}" target="_blank" rel="noopener" style="display:inline-block;margin:4px 6px 0 0;color:var(--accent);text-decoration:underline;font-size:0.72rem">
${f.channel}${f.contactName}
</a>`).join('')}`;
};
window.sendReportAll = async function(contactId, contactName, email, whatsapp, phone) {
// Open ALL channels first within the same user-gesture tick — browsers
// allow multiple popups when triggered from a single click. Logging
// happens after, async, so it never blocks the channel opening.
const modal = document.getElementById('modal-report');
const alertObj = JSON.parse(modal.dataset.alertJson);
const message = document.getElementById('report-message').value;
const subject = `[AidsMonitoring] ${alertObj.tipo || 'Alert'}${alertObj.aid_nombre || alertObj.aid_id || ''}`;
if (email) _openChannel('EMAIL', email, subject, message);
if (whatsapp) _openChannel('WHATSAPP', whatsapp, subject, message);
if (phone) _openChannel('SMS', phone, subject, message);
// Audit log + UI badge for each — does not depend on opening order
if (email) _logAndMarkReport(contactId, contactName, 'EMAIL', email, alertObj, message);
if (whatsapp) _logAndMarkReport(contactId, contactName, 'WHATSAPP', whatsapp, alertObj, message);
if (phone) _logAndMarkReport(contactId, contactName, 'SMS', phone, alertObj, message);
};
async function _logAndMarkReport(contactId, contactName, channel, recipient, alertObj, message) {
let smtpSent = false, smtpDetail = '';
try {
const r = await fetch(`${API}/alerts/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.Auth?.token()}`,
},
body: JSON.stringify({
alert_id: alertObj.id,
alert_tipo: alertObj.tipo,
alert_subtipo: alertObj.subtipo,
aid_id: alertObj.aid_id,
aid_nombre: alertObj.aid_nombre,
mmsi: alertObj.mmsi,
contact_id: contactId,
contact_name: contactName,
channel, recipient, message,
}),
});
if (r.ok) {
const d = await r.json();
smtpSent = !!d.smtp_sent;
smtpDetail = d.smtp_detail || '';
}
} catch {}
// EMAIL fallback: if backend SMTP didn't send, open the operator's mail client
if (channel === 'EMAIL' && !smtpSent) {
const subject = `[AidsMonitoring] ${alertObj.tipo || 'Alert'}${alertObj.aid_nombre || alertObj.aid_id || ''}`;
_openChannel('EMAIL', recipient, subject, message);
}
const modal = document.getElementById('modal-report');
const itemEl = document.querySelector(`.event-item[data-alert-id="${modal.dataset.alertId}"]`);
if (itemEl) {
const badge = itemEl.querySelector('.ev-reported');
badge.classList.remove('hidden');
const ts = new Date().toUTCString().slice(17,25);
const usr = window.Auth?.session?.nombre || 'operator';
const tag = (channel === 'EMAIL' && smtpSent) ? 'EMAIL ✉ SMTP' : channel;
badge.innerHTML += `<div>✓ Reported to <strong>${contactName}</strong> via ${tag}${ts} by ${usr}</div>`;
itemEl.classList.add('ev-reported-state');
}
}
// Fire a channel using a synthetic anchor click. Browsers treat
// anchor.click() inside a user-gesture handler as user-initiated, so the
// popup blocker is more lenient than with window.open() — that's the only
// way to reliably fire multiple channels from one click. Returns the URL
// fired (so the caller can offer a fallback link if Chrome still blocks it).
function _openChannel(channel, recipient, subject, message) {
let url = null;
if (channel === 'EMAIL') {
url = `mailto:${recipient}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(message)}`;
} else if (channel === 'WHATSAPP') {
const phone = recipient.replace(/[^0-9]/g, '');
url = `https://web.whatsapp.com/send?phone=${phone}&text=${encodeURIComponent(message)}`;
} else if (channel === 'SMS') {
url = `sms:${recipient}?body=${encodeURIComponent(message)}`;
}
if (!url) return null;
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
return url;
}
window.sendReport = async function(contactId, contactName, channel, recipient) {
const modal = document.getElementById('modal-report');
const alertObj = JSON.parse(modal.dataset.alertJson);
const message = document.getElementById('report-message').value;
const subject = `[AidsMonitoring] ${alertObj.tipo || 'Alert'}${alertObj.aid_nombre || alertObj.aid_id || ''}`;
const status = document.getElementById('report-status');
// For EMAIL we POST first; if backend SMTP is configured, the email is
// sent server-side and we skip mailto. WhatsApp/SMS still use anchors.
if (channel !== 'EMAIL') {
_openChannel(channel, recipient, subject, message);
}
// Log the report (audit trail)
try {
await fetch(`${API}/alerts/report`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.Auth?.token()}`,
},
body: JSON.stringify({
alert_id: alertObj.id,
alert_tipo: alertObj.tipo,
alert_subtipo: alertObj.subtipo,
aid_id: alertObj.aid_id,
aid_nombre: alertObj.aid_nombre,
mmsi: alertObj.mmsi,
contact_id: contactId,
contact_name: contactName,
channel: channel,
recipient: recipient,
message: message,
}),
});
} catch (e) {
console.warn('Could not log report:', e);
}
// Mark the originating event card as REPORTED
const itemEl = document.querySelector(`.event-item[data-alert-id="${modal.dataset.alertId}"]`);
if (itemEl) {
const badge = itemEl.querySelector('.ev-reported');
badge.classList.remove('hidden');
const ts = new Date().toUTCString().slice(17,25);
const usr = window.Auth?.session?.nombre || 'operator';
const prev = badge.innerHTML;
badge.innerHTML = (prev || '') + `<div>✓ Reported to <strong>${contactName}</strong> via ${channel}${ts} by ${usr}</div>`;
itemEl.classList.add('ev-reported-state');
// Hide the REPORT button after first send (still visible if more recipients)
}
status.textContent = `Sent to ${contactName} via ${channel}.`;
status.className = 'save-status ok';
};
[
['btn-close-report', 'modal-report'],
['btn-close-report2', 'modal-report'],
].forEach(([btnId, modalId]) => {
document.getElementById(btnId)?.addEventListener('click', () => _hideModal(modalId));
});
// ── STATUS BAR ────────────────────────────────────────────────────────────
function setDot(dotId, labelId, text, color) {
const dot = document.getElementById(dotId);
const lbl = document.getElementById(labelId);
if (dot) { dot.className = `sb-dot ${color}`; }
if (lbl) lbl.textContent = text;
}
function updateStatusBar() {
const user = window.Auth?.session;
const sbUser = document.getElementById('sb-user');
if (sbUser) sbUser.textContent = user ? `${user.nombre} · ${user.role}` : '';
}
// Poll /ais/stats every 5 s to show real signal state in status bar
async function _pollAisStats() {
try {
const r = await fetch('/ais/stats');
const d = await r.json();
const src = (d.ais_source || 'SIMULATOR').toUpperCase();
if (src === 'SIMULATOR') {
setDot('sb-dot-ais', 'sb-ais', 'AIS: SIMULATOR', 'yellow');
return;
}
const msgs = d.sentences_received ?? 0;
const secs = d.seconds_since_last;
const hasSignal = d.signal === true;
const running = d.catcher_running;
let label, color;
if (!running) {
label = `AIS: ${src} — no catcher`;
color = 'grey';
} else if (!hasSignal) {
const ago = secs != null ? ` (${secs}s)` : ' (sin señal)';
label = `AIS: ${src}${ago}`;
color = 'red';
} else {
label = `AIS: ${src}${msgs} msg`;
color = 'green';
}
setDot('sb-dot-ais', 'sb-ais', label, color);
} catch (e) {
setDot('sb-dot-ais', 'sb-ais', 'AIS: ?', 'grey');
}
}
document.addEventListener('DOMContentLoaded', () => {
updateStatusBar();
_pollAisStats();
setInterval(_pollAisStats, 5000);
});
// Sync WS dot to ws-status indicator
const _origWsStatus = document.getElementById('ws-status');
if (_origWsStatus) {
new MutationObserver(() => {
const online = _origWsStatus.classList.contains('online');
setDot('sb-dot-ws', 'sb-ws',
`WS: ${online ? 'ONLINE' : 'OFFLINE'}`,
online ? 'green' : 'grey');
}).observe(_origWsStatus, { childList: true, attributes: true, classList: true });
}