mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-26 19:42:43 +02:00
feat(observer): Settings > Observer tab UI
New Observer tab: master switch, IATA location code, flood advert interval, live status line with packet counters, broker list with connection badges (green/red + error tooltip), enable switches and a stacked add/edit modal (blank password on edit keeps the stored one). Live badge/counter updates via the observer_status socket event. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -484,6 +484,11 @@ function connectChatSocket() {
|
||||
chatSocket.on('device_status', (data) => {
|
||||
updateStatus(data.connected ? 'connected' : 'disconnected');
|
||||
});
|
||||
|
||||
// Observer live status (Settings > Observer tab badges + counters)
|
||||
chatSocket.on('observer_status', (data) => {
|
||||
applyObserverLiveStatus(data || {});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
@@ -2572,6 +2577,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadContactsSettings();
|
||||
loadRegions();
|
||||
loadAnalyzers();
|
||||
loadObserverTab();
|
||||
});
|
||||
settingsModal.addEventListener('shown.bs.modal', () => {
|
||||
settingsModal.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
@@ -2631,6 +2637,29 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Observer tab: settings + broker add/edit form
|
||||
document.getElementById('observerEnabledToggle')?.addEventListener('change', (e) => {
|
||||
saveObserverSettings({ enabled: e.target.checked });
|
||||
});
|
||||
document.getElementById('observerIataInput')?.addEventListener('change', (e) => {
|
||||
const iata = (e.target.value || '').trim();
|
||||
if (iata && !/^[A-Za-z]{3}$/.test(iata)) {
|
||||
showNotification('Location code must be empty or exactly 3 letters', 'warning');
|
||||
return;
|
||||
}
|
||||
saveObserverSettings({ iata });
|
||||
});
|
||||
document.getElementById('observerAdvertIntervalInput')?.addEventListener('change', (e) => {
|
||||
saveObserverSettings({ advert_interval_hours: parseInt(e.target.value || '0', 10) || 0 });
|
||||
});
|
||||
document.getElementById('addObserverBrokerBtn')?.addEventListener('click', () => {
|
||||
openObserverBrokerModal(null);
|
||||
});
|
||||
document.getElementById('observerBrokerEditForm')?.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
saveObserverBrokerFromForm();
|
||||
});
|
||||
|
||||
// Preload analyzers so the first click on a chart icon doesn't need a round-trip.
|
||||
loadAnalyzers();
|
||||
|
||||
@@ -3466,6 +3495,283 @@ function openAnalyzerChooser(packetHash, enabled) {
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Observer (Settings > Observer): MQTT packet capture
|
||||
// ================================================================
|
||||
|
||||
window.observerCache = window.observerCache || {
|
||||
status: null,
|
||||
brokers: [],
|
||||
};
|
||||
|
||||
async function loadObserverTab() {
|
||||
try {
|
||||
const resp = await fetch('/api/observer/status');
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed');
|
||||
window.observerCache.status = data.status;
|
||||
window.observerCache.brokers = data.status.brokers || [];
|
||||
renderObserverSettings(data.status);
|
||||
renderObserverBrokers();
|
||||
renderObserverStatusLine(data.status);
|
||||
} catch (e) {
|
||||
console.error('Error loading observer status:', e);
|
||||
const listEl = document.getElementById('observerBrokersList');
|
||||
if (listEl) {
|
||||
listEl.innerHTML = '<div class="text-center text-danger small py-2">Failed to load observer status</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderObserverSettings(status) {
|
||||
const s = status.settings || {};
|
||||
const enabledEl = document.getElementById('observerEnabledToggle');
|
||||
const iataEl = document.getElementById('observerIataInput');
|
||||
const advertEl = document.getElementById('observerAdvertIntervalInput');
|
||||
if (enabledEl) enabledEl.checked = !!s.enabled;
|
||||
// Don't clobber a field the user is typing in
|
||||
if (iataEl && document.activeElement !== iataEl) iataEl.value = (s.iata || '').toUpperCase();
|
||||
if (advertEl && document.activeElement !== advertEl) advertEl.value = s.advert_interval_hours || 0;
|
||||
}
|
||||
|
||||
function renderObserverStatusLine(status) {
|
||||
const el = document.getElementById('observerStatusLine');
|
||||
if (!el || !status) return;
|
||||
if (!status.enabled) {
|
||||
el.innerHTML = 'Observer is <strong>off</strong>.';
|
||||
return;
|
||||
}
|
||||
const state = status.running
|
||||
? '<span class="badge bg-success">running</span>'
|
||||
: `<span class="badge bg-warning text-dark">waiting</span> ${escapeHtml(status.reason || '')}`;
|
||||
el.innerHTML = `${state} — packets captured: <strong>${status.packets_seen ?? 0}</strong>,`
|
||||
+ ` published: <strong>${status.packets_published ?? 0}</strong>`;
|
||||
}
|
||||
|
||||
function observerBrokerBadgeParts(b) {
|
||||
if (b.connected) return { cls: 'bg-success', txt: 'connected', title: '' };
|
||||
if (b.last_error) return { cls: 'bg-danger', txt: 'error', title: b.last_error };
|
||||
return { cls: 'bg-secondary', txt: 'offline', title: '' };
|
||||
}
|
||||
|
||||
function renderObserverBrokers() {
|
||||
const listEl = document.getElementById('observerBrokersList');
|
||||
if (!listEl) return;
|
||||
const brokers = window.observerCache.brokers || [];
|
||||
|
||||
if (brokers.length === 0) {
|
||||
listEl.innerHTML =
|
||||
'<div class="text-center text-muted small py-3">No brokers configured. Click "Add broker" to add one.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = brokers.map(b => {
|
||||
const enabled = !b.is_disabled;
|
||||
const nameClass = enabled ? '' : 'text-muted text-decoration-line-through';
|
||||
const safeName = escapeHtml(b.name);
|
||||
const tlsBadge = b.use_tls ? '<span class="badge bg-info text-dark ms-1">TLS</span>' : '';
|
||||
const badge = b.is_disabled
|
||||
? '<span class="badge bg-secondary ms-1">Disabled</span>'
|
||||
: (() => {
|
||||
const p = observerBrokerBadgeParts(b);
|
||||
return `<span class="badge ${p.cls} ms-1" id="observerBrokerBadge_${b.id}" title="${escapeHtml(p.title)}">${p.txt}</span>`;
|
||||
})();
|
||||
const userInfo = b.username ? `${escapeHtml(b.username)}@` : '';
|
||||
return `
|
||||
<div class="list-group-item d-flex align-items-center gap-2 py-2">
|
||||
<div class="flex-grow-1" style="min-width: 0;">
|
||||
<div class="${nameClass}"><strong>${safeName}</strong>${tlsBadge}${badge}</div>
|
||||
<code class="small text-muted text-break" style="word-break: break-all;">${userInfo}${escapeHtml(b.host)}:${b.port}</code>
|
||||
</div>
|
||||
<div class="form-check form-switch mb-0" title="${enabled ? 'Enabled' : 'Disabled'}">
|
||||
<input class="form-check-input" type="checkbox" ${enabled ? 'checked' : ''}
|
||||
onchange="toggleObserverBrokerDisabled(${b.id}, !this.checked)">
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="openObserverBrokerModal(${b.id})" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
onclick="deleteObserverBroker(${b.id}, '${safeName.replace(/'/g, "\\'")}')" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
listEl.innerHTML = rows;
|
||||
}
|
||||
|
||||
// Live updates pushed by the backend (observer_status socket event).
|
||||
// Payload has no settings/broker rows — only counters + per-client state.
|
||||
function applyObserverLiveStatus(live) {
|
||||
renderObserverStatusLine({ ...live, settings: undefined });
|
||||
(live.brokers || []).forEach(b => {
|
||||
const badge = document.getElementById(`observerBrokerBadge_${b.id}`);
|
||||
if (!badge) return;
|
||||
const p = observerBrokerBadgeParts(b);
|
||||
badge.className = `badge ${p.cls} ms-1`;
|
||||
badge.textContent = p.txt;
|
||||
badge.title = p.title;
|
||||
});
|
||||
}
|
||||
|
||||
function openObserverBrokerModal(id) {
|
||||
const modalEl = document.getElementById('observerBrokerEditModal');
|
||||
if (!modalEl) return;
|
||||
const titleEl = document.getElementById('observerBrokerEditModalTitle');
|
||||
const idEl = document.getElementById('observerBrokerEditId');
|
||||
const nameEl = document.getElementById('observerBrokerEditName');
|
||||
const hostEl = document.getElementById('observerBrokerEditHost');
|
||||
const portEl = document.getElementById('observerBrokerEditPort');
|
||||
const userEl = document.getElementById('observerBrokerEditUsername');
|
||||
const passEl = document.getElementById('observerBrokerEditPassword');
|
||||
const passHintEl = document.getElementById('observerBrokerPasswordHint');
|
||||
const tlsEl = document.getElementById('observerBrokerEditTls');
|
||||
const tlsVerifyEl = document.getElementById('observerBrokerEditTlsVerify');
|
||||
const enabledEl = document.getElementById('observerBrokerEditEnabled');
|
||||
const errorEl = document.getElementById('observerBrokerEditError');
|
||||
|
||||
errorEl.classList.add('d-none');
|
||||
errorEl.textContent = '';
|
||||
passEl.value = '';
|
||||
|
||||
if (id) {
|
||||
const b = (window.observerCache.brokers || []).find(x => x.id === id);
|
||||
if (!b) return;
|
||||
titleEl.textContent = 'Edit broker';
|
||||
idEl.value = String(b.id);
|
||||
nameEl.value = b.name || '';
|
||||
hostEl.value = b.host || '';
|
||||
portEl.value = b.port || 1883;
|
||||
userEl.value = b.username || '';
|
||||
tlsEl.checked = !!b.use_tls;
|
||||
tlsVerifyEl.checked = !!b.tls_verify;
|
||||
enabledEl.checked = !b.is_disabled;
|
||||
passHintEl.classList.toggle('d-none', !b.has_password);
|
||||
} else {
|
||||
titleEl.textContent = 'Add broker';
|
||||
idEl.value = '';
|
||||
nameEl.value = '';
|
||||
hostEl.value = '';
|
||||
portEl.value = 1883;
|
||||
userEl.value = '';
|
||||
tlsEl.checked = false;
|
||||
tlsVerifyEl.checked = true;
|
||||
enabledEl.checked = true;
|
||||
passHintEl.classList.add('d-none');
|
||||
}
|
||||
|
||||
modalEl.addEventListener('shown.bs.modal', _bumpAnalyzerBackdrop, { once: true });
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||||
}
|
||||
|
||||
function showObserverBrokerFormError(msg) {
|
||||
const errorEl = document.getElementById('observerBrokerEditError');
|
||||
if (!errorEl) return;
|
||||
errorEl.textContent = msg;
|
||||
errorEl.classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function saveObserverBrokerFromForm() {
|
||||
const id = document.getElementById('observerBrokerEditId').value
|
||||
? parseInt(document.getElementById('observerBrokerEditId').value, 10) : null;
|
||||
const name = (document.getElementById('observerBrokerEditName').value || '').trim();
|
||||
const host = (document.getElementById('observerBrokerEditHost').value || '').trim();
|
||||
const port = parseInt(document.getElementById('observerBrokerEditPort').value || '1883', 10);
|
||||
const username = (document.getElementById('observerBrokerEditUsername').value || '').trim();
|
||||
const password = document.getElementById('observerBrokerEditPassword').value;
|
||||
const use_tls = document.getElementById('observerBrokerEditTls').checked;
|
||||
const tls_verify = document.getElementById('observerBrokerEditTlsVerify').checked;
|
||||
const is_disabled = !document.getElementById('observerBrokerEditEnabled').checked;
|
||||
|
||||
if (!name) { showObserverBrokerFormError('Name is required'); return; }
|
||||
if (!host) { showObserverBrokerFormError('Host is required'); return; }
|
||||
if (!(port >= 1 && port <= 65535)) { showObserverBrokerFormError('Port must be 1-65535'); return; }
|
||||
|
||||
const body = { name, host, port, username, use_tls, tls_verify, is_disabled };
|
||||
// Edit mode: an empty password field means "keep the stored password"
|
||||
if (!id || password !== '') body.password = password;
|
||||
|
||||
try {
|
||||
const url = id ? `/api/observer/brokers/${id}` : '/api/observer/brokers';
|
||||
const resp = await fetch(url, {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.success) {
|
||||
showObserverBrokerFormError(data.error || 'Failed to save broker');
|
||||
return;
|
||||
}
|
||||
bootstrap.Modal.getInstance(document.getElementById('observerBrokerEditModal'))?.hide();
|
||||
// Reload once now and once after the reconnect settles
|
||||
await loadObserverTab();
|
||||
setTimeout(loadObserverTab, 1500);
|
||||
} catch (e) {
|
||||
console.error('Error saving observer broker:', e);
|
||||
showObserverBrokerFormError('Network error saving broker');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteObserverBroker(id, name) {
|
||||
if (!confirm(`Delete broker "${name}"?`)) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/observer/brokers/${id}`, { method: 'DELETE' });
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.success) {
|
||||
showNotification(data.error || 'Failed to delete broker', 'danger');
|
||||
return;
|
||||
}
|
||||
await loadObserverTab();
|
||||
} catch (e) {
|
||||
console.error('Error deleting observer broker:', e);
|
||||
showNotification('Network error deleting broker', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleObserverBrokerDisabled(id, disabled) {
|
||||
try {
|
||||
const resp = await fetch(`/api/observer/brokers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_disabled: !!disabled }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.success) {
|
||||
showNotification(data.error || 'Failed to update broker', 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error toggling observer broker:', e);
|
||||
showNotification('Network error updating broker', 'danger');
|
||||
}
|
||||
await loadObserverTab();
|
||||
setTimeout(loadObserverTab, 1500);
|
||||
}
|
||||
|
||||
async function saveObserverSettings(patch) {
|
||||
try {
|
||||
const resp = await fetch('/api/observer/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.success) {
|
||||
showNotification(data.error || 'Failed to save observer settings', 'danger');
|
||||
await loadObserverTab();
|
||||
return;
|
||||
}
|
||||
// The reload runs on a backend thread; refresh after it settles
|
||||
setTimeout(loadObserverTab, 1500);
|
||||
} catch (e) {
|
||||
console.error('Error saving observer settings:', e);
|
||||
showNotification('Network error saving observer settings', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Per-channel region picker (Manage Channels > row > pin icon)
|
||||
// ================================================================
|
||||
|
||||
@@ -459,6 +459,9 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAnalyzer" type="button">Analyzer</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsObserver" type="button">Observer</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsNotifications" type="button">Notifications</button>
|
||||
</li>
|
||||
@@ -1009,6 +1012,45 @@
|
||||
Disabled analyzers stay in the list but are hidden from the chooser.
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tabSettingsObserver">
|
||||
<div class="alert alert-info py-2 small mb-3 mt-2">
|
||||
<i class="bi bi-info-circle"></i> Publish every mesh packet this device overhears to
|
||||
MQTT brokers, in the <code>meshcore-packet-capture</code> format
|
||||
(topics <code>meshcore/IATA/PUBKEY/packets</code>). Compatible with
|
||||
letsmesh-style packet analyzers.
|
||||
</div>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="observerEnabledToggle">
|
||||
<label class="form-check-label" for="observerEnabledToggle">Enable observer</label>
|
||||
</div>
|
||||
<div class="row g-2 mb-1">
|
||||
<div class="col-6">
|
||||
<label class="form-label small mb-1" for="observerIataInput">Location code (IATA)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="observerIataInput"
|
||||
maxlength="3" placeholder="e.g. KRK" autocomplete="off"
|
||||
style="text-transform: uppercase;">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small mb-1" for="observerAdvertIntervalInput">Flood advert interval (h)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="observerAdvertIntervalInput"
|
||||
min="0" max="8760" step="1" placeholder="0 = off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text small mb-2">
|
||||
The 3-letter location code is used in MQTT topics; leave it empty for the flat
|
||||
<code>meshcore/packets</code> topics. Advert interval 0 disables scheduled flood adverts.
|
||||
</div>
|
||||
<div id="observerStatusLine" class="small text-muted mb-3"></div>
|
||||
<h6 class="mb-2">MQTT Brokers</h6>
|
||||
<div id="observerBrokersList" class="list-group mb-3">
|
||||
<div class="text-center text-muted py-3 small">
|
||||
<div class="spinner-border spinner-border-sm"></div> Loading...
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="addObserverBrokerBtn">
|
||||
<i class="bi bi-plus-circle"></i> Add broker
|
||||
</button>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tabSettingsNotifications">
|
||||
<p class="text-muted small mb-3">Browser notifications appear when the app is hidden or in the background.</p>
|
||||
<button id="notificationsToggle" class="list-group-item list-group-item-action d-flex align-items-center gap-3 border rounded" type="button" style="width: 100%;">
|
||||
@@ -1085,6 +1127,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observer Broker Edit Modal (create + edit) -->
|
||||
<div class="modal fade" id="observerBrokerEditModal" tabindex="-1" style="z-index: 1080;">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-broadcast"></i> <span id="observerBrokerEditModalTitle">Add broker</span></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="observerBrokerEditForm">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="observerBrokerEditId" value="">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1" for="observerBrokerEditName">Name</label>
|
||||
<input type="text" class="form-control form-control-sm" id="observerBrokerEditName"
|
||||
placeholder="e.g. My MQTT server" required maxlength="60" autocomplete="off">
|
||||
</div>
|
||||
<div class="row g-2 mb-1">
|
||||
<div class="col-8">
|
||||
<label class="form-label small mb-1" for="observerBrokerEditHost">Host</label>
|
||||
<input type="text" class="form-control form-control-sm" id="observerBrokerEditHost"
|
||||
placeholder="mqtt.example.com" required autocomplete="off">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small mb-1" for="observerBrokerEditPort">Port</label>
|
||||
<input type="number" class="form-control form-control-sm" id="observerBrokerEditPort"
|
||||
min="1" max="65535" value="1883" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text small mb-2">Typical ports: 1883 (plain), 8883 (TLS).</div>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small mb-1" for="observerBrokerEditUsername">Username</label>
|
||||
<input type="text" class="form-control form-control-sm" id="observerBrokerEditUsername"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small mb-1" for="observerBrokerEditPassword">Password</label>
|
||||
<input type="password" class="form-control form-control-sm" id="observerBrokerEditPassword"
|
||||
autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text small mb-2 d-none" id="observerBrokerPasswordHint">
|
||||
Leave the password blank to keep the current one.
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="observerBrokerEditTls">
|
||||
<label class="form-check-label small" for="observerBrokerEditTls">Use TLS</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="observerBrokerEditTlsVerify" checked>
|
||||
<label class="form-check-label small" for="observerBrokerEditTlsVerify">Verify TLS certificate</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="observerBrokerEditEnabled" checked>
|
||||
<label class="form-check-label small" for="observerBrokerEditEnabled">Enabled</label>
|
||||
</div>
|
||||
<div id="observerBrokerEditError" class="alert alert-danger py-1 small mt-3 d-none"></div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coordinate Picker Map Modal -->
|
||||
<div class="modal fade" id="coordPickerModal" tabindex="-1" style="z-index: 1080;">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
|
||||
Reference in New Issue
Block a user