581 lines
24 KiB
JavaScript
581 lines
24 KiB
JavaScript
'use strict';
|
||
|
||
const App = (() => {
|
||
|
||
// ── State ──────────────────────────────────────────────────────────────────
|
||
let currentPage = 'dashboard';
|
||
let editingWebhookId = null;
|
||
const PAGE_SIZE = 20;
|
||
let receivedOffset = 0;
|
||
let sentOffset = 0;
|
||
|
||
// ── API helper ─────────────────────────────────────────────────────────────
|
||
async function api(method, path, body) {
|
||
const opts = {
|
||
method,
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
};
|
||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||
const res = await fetch('/api' + path, opts);
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||
return data;
|
||
}
|
||
|
||
// ── Toast ──────────────────────────────────────────────────────────────────
|
||
function toast(msg, type = 'info') {
|
||
const el = document.createElement('div');
|
||
el.className = `toast toast-${type}`;
|
||
const icons = { success: '✅', error: '❌', info: 'ℹ️' };
|
||
el.innerHTML = `<span>${icons[type] || ''}</span><span>${msg}</span>`;
|
||
document.getElementById('toast-container').appendChild(el);
|
||
setTimeout(() => el.remove(), 4000);
|
||
}
|
||
|
||
function showAlert(id, msg, type = 'danger') {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.className = `alert alert-${type}`;
|
||
el.textContent = msg;
|
||
el.style.display = 'flex';
|
||
}
|
||
function hideAlert(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = 'none';
|
||
}
|
||
|
||
// ── Formatters ─────────────────────────────────────────────────────────────
|
||
function fmt(dateStr) {
|
||
if (!dateStr) return '—';
|
||
try {
|
||
return new Date(dateStr).toLocaleString();
|
||
} catch { return dateStr; }
|
||
}
|
||
|
||
function statusBadge(status) {
|
||
const map = {
|
||
sent: ['badge-blue', 'Sent'],
|
||
received: ['badge-green', 'Received'],
|
||
failed: ['badge-red', 'Failed'],
|
||
pending: ['badge-yellow', 'Pending'],
|
||
};
|
||
const [cls, label] = map[status] || ['badge-gray', status];
|
||
return `<span class="badge ${cls}">${label}</span>`;
|
||
}
|
||
|
||
function esc(str) {
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||
function navigate(page) {
|
||
currentPage = page;
|
||
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||
document.getElementById(`page-${page}`)?.classList.add('active');
|
||
document.querySelector(`.nav-item[data-page="${page}"]`)?.classList.add('active');
|
||
|
||
// Load data for page
|
||
const loaders = {
|
||
dashboard: loadDashboard,
|
||
received: loadReceived,
|
||
sent: loadSent,
|
||
webhooks: loadWebhooks,
|
||
tokens: loadTokens,
|
||
settings: loadSettings,
|
||
};
|
||
loaders[page]?.();
|
||
}
|
||
|
||
// ── Auth ───────────────────────────────────────────────────────────────────
|
||
async function checkAuth() {
|
||
try {
|
||
const me = await api('GET', '/auth/me');
|
||
if (me.authenticated) {
|
||
showApp(me.username);
|
||
loadDashboard();
|
||
} else {
|
||
showLogin();
|
||
}
|
||
} catch {
|
||
showLogin();
|
||
}
|
||
}
|
||
|
||
function showLogin() {
|
||
document.getElementById('login-page').classList.add('active');
|
||
document.getElementById('sidebar').style.display = 'none';
|
||
document.getElementById('main').style.display = 'none';
|
||
}
|
||
|
||
function showApp(username) {
|
||
document.getElementById('login-page').classList.remove('active');
|
||
document.getElementById('sidebar').style.display = 'flex';
|
||
document.getElementById('main').style.display = 'flex';
|
||
document.getElementById('user-name').textContent = username || 'Admin';
|
||
document.getElementById('user-avatar').textContent = (username || 'A')[0].toUpperCase();
|
||
}
|
||
|
||
async function login(e) {
|
||
e.preventDefault();
|
||
const username = document.getElementById('login-username').value.trim();
|
||
const password = document.getElementById('login-password').value;
|
||
hideAlert('login-error');
|
||
try {
|
||
await api('POST', '/auth/login', { username, password });
|
||
showApp(username);
|
||
loadDashboard();
|
||
} catch (err) {
|
||
showAlert('login-error', err.message || 'Login failed');
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
await api('POST', '/auth/logout');
|
||
showLogin();
|
||
}
|
||
|
||
// ── Dashboard ──────────────────────────────────────────────────────────────
|
||
async function loadDashboard() {
|
||
try {
|
||
const [stats, msgs] = await Promise.all([
|
||
api('GET', '/stats'),
|
||
api('GET', '/sms/messages?limit=10'),
|
||
]);
|
||
document.getElementById('stat-sent').textContent = stats.sent ?? 0;
|
||
document.getElementById('stat-received').textContent = stats.received ?? 0;
|
||
document.getElementById('stat-failed').textContent = stats.failed ?? 0;
|
||
|
||
const tbody = document.getElementById('recent-messages');
|
||
if (!msgs.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><div class="icon">📭</div>No messages yet</div></td></tr>';
|
||
} else {
|
||
tbody.innerHTML = msgs.map(m => `
|
||
<tr>
|
||
<td><span class="dir-indicator" title="${m.direction}">${m.direction === 'received' ? '📥' : '📤'}</span></td>
|
||
<td><span class="phone">${esc(m.phone_number)}</span></td>
|
||
<td><span class="msg-text" title="${esc(m.message)}">${esc(m.message)}</span></td>
|
||
<td>${statusBadge(m.status)}</td>
|
||
<td style="color:var(--muted);font-size:12px;">${fmt(m.created_at)}</td>
|
||
</tr>`).join('');
|
||
}
|
||
} catch (err) {
|
||
toast('Failed to load dashboard: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function refreshDashboard() { loadDashboard(); }
|
||
|
||
// ── Received ───────────────────────────────────────────────────────────────
|
||
async function loadReceived(offset = 0) {
|
||
receivedOffset = offset;
|
||
const tbody = document.getElementById('received-list');
|
||
tbody.innerHTML = '<tr><td colspan="3"><div class="empty-state"><div class="spinner"></div></div></td></tr>';
|
||
try {
|
||
const msgs = await api('GET', `/sms/messages/received?limit=${PAGE_SIZE}&offset=${offset}`);
|
||
if (!msgs.length) {
|
||
tbody.innerHTML = '<tr><td colspan="3"><div class="empty-state"><div class="icon">📭</div>No received messages</div></td></tr>';
|
||
} else {
|
||
tbody.innerHTML = msgs.map(m => `
|
||
<tr>
|
||
<td><span class="phone">${esc(m.phone_number)}</span></td>
|
||
<td>${esc(m.message)}</td>
|
||
<td style="color:var(--muted);font-size:12px;">${fmt(m.created_at)}</td>
|
||
</tr>`).join('');
|
||
}
|
||
renderPagination('received-pagination', offset, msgs.length, loadReceived);
|
||
} catch (err) {
|
||
toast('Failed to load received messages: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Sent ───────────────────────────────────────────────────────────────────
|
||
async function loadSent(offset = 0) {
|
||
sentOffset = offset;
|
||
const tbody = document.getElementById('sent-list');
|
||
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-state"><div class="spinner"></div></div></td></tr>';
|
||
try {
|
||
const msgs = await api('GET', `/sms/messages/sent?limit=${PAGE_SIZE}&offset=${offset}`);
|
||
if (!msgs.length) {
|
||
tbody.innerHTML = '<tr><td colspan="4"><div class="empty-state"><div class="icon">📤</div>No sent messages</div></td></tr>';
|
||
} else {
|
||
tbody.innerHTML = msgs.map(m => `
|
||
<tr>
|
||
<td><span class="phone">${esc(m.phone_number)}</span></td>
|
||
<td>${esc(m.message)}</td>
|
||
<td>${statusBadge(m.status)}</td>
|
||
<td style="color:var(--muted);font-size:12px;">${fmt(m.created_at)}</td>
|
||
</tr>`).join('');
|
||
}
|
||
renderPagination('sent-pagination', offset, msgs.length, loadSent);
|
||
} catch (err) {
|
||
toast('Failed to load sent messages: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function renderPagination(id, offset, count, loader) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
const hasPrev = offset > 0;
|
||
const hasNext = count === PAGE_SIZE;
|
||
el.innerHTML = `
|
||
${hasPrev ? `<button class="btn btn-sm btn-outline" onclick="App._paginate('${id}',${offset - PAGE_SIZE})">← Prev</button>` : ''}
|
||
<span style="color:var(--muted);font-size:12px;">Page ${Math.floor(offset / PAGE_SIZE) + 1}</span>
|
||
${hasNext ? `<button class="btn btn-sm btn-outline" onclick="App._paginate('${id}',${offset + PAGE_SIZE})">Next →</button>` : ''}
|
||
`;
|
||
}
|
||
|
||
function _paginate(paginationId, offset) {
|
||
if (paginationId === 'received-pagination') loadReceived(offset);
|
||
else if (paginationId === 'sent-pagination') loadSent(offset);
|
||
}
|
||
|
||
// ── Send SMS ───────────────────────────────────────────────────────────────
|
||
async function sendSMS(e) {
|
||
e.preventDefault();
|
||
const number = document.getElementById('send-number').value.trim();
|
||
const message = document.getElementById('send-message').value.trim();
|
||
hideAlert('send-alert');
|
||
if (!number || !message) {
|
||
showAlert('send-alert', 'Phone number and message are required');
|
||
return;
|
||
}
|
||
const btn = document.getElementById('send-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Sending…';
|
||
try {
|
||
await api('POST', '/sms/send', { number, message });
|
||
toast(`Message sent to ${number}`, 'success');
|
||
document.getElementById('send-number').value = '';
|
||
document.getElementById('send-message').value = '';
|
||
document.getElementById('char-count').textContent = '0';
|
||
} catch (err) {
|
||
showAlert('send-alert', 'Failed to send: ' + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = '✉️ Send Message';
|
||
}
|
||
}
|
||
|
||
async function triggerPoll() {
|
||
try {
|
||
await api('POST', '/sms/poll');
|
||
toast('Device polled for new messages', 'success');
|
||
if (currentPage === 'received') loadReceived(receivedOffset);
|
||
} catch (err) {
|
||
toast('Poll failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Webhooks ───────────────────────────────────────────────────────────────
|
||
async function loadWebhooks() {
|
||
const tbody = document.getElementById('webhook-list');
|
||
try {
|
||
const hooks = await api('GET', '/webhooks');
|
||
if (!hooks.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><div class="icon">🔗</div>No webhooks configured</div></td></tr>';
|
||
} else {
|
||
tbody.innerHTML = hooks.map(h => {
|
||
const hookUrl = `${window.location.origin}/api/hooks/${h.token}`;
|
||
return `
|
||
<tr>
|
||
<td><strong>${esc(h.name)}</strong></td>
|
||
<td><span class="phone">${esc(h.phone_number)}</span></td>
|
||
<td style="font-size:12px;">
|
||
<span style="font-family:monospace;word-break:break-all;">${esc(hookUrl)}</span>
|
||
<button class="btn btn-sm btn-outline btn-icon" style="margin-left:6px;"
|
||
onclick="App.copyWebhookUrl('${esc(hookUrl)}')" title="Copy URL">📋</button>
|
||
</td>
|
||
<td>
|
||
<label class="toggle">
|
||
<input type="checkbox" ${h.active ? 'checked' : ''} onchange="App.toggleWebhook(${h.id}, this.checked)" />
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</td>
|
||
<td style="display:flex;gap:6px;">
|
||
<button class="btn btn-sm btn-outline btn-icon" onclick="App.openWebhookModal(${h.id})" title="Edit">✏️</button>
|
||
<button class="btn btn-sm btn-danger btn-icon" onclick="App.deleteWebhook(${h.id})" title="Delete">🗑</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
} catch (err) {
|
||
toast('Failed to load webhooks: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function copyWebhookUrl(url) {
|
||
navigator.clipboard?.writeText(url).then(() => toast('Webhook URL copied', 'success'));
|
||
}
|
||
|
||
function openWebhookModal(id) {
|
||
editingWebhookId = id || null;
|
||
document.getElementById('webhook-modal-title').textContent = id ? 'Edit Webhook' : 'Add Webhook';
|
||
['wh-name', 'wh-phone', 'wh-template'].forEach(f => document.getElementById(f).value = '');
|
||
|
||
if (id) {
|
||
api('GET', '/webhooks').then(hooks => {
|
||
const h = hooks.find(x => x.id === id);
|
||
if (!h) return;
|
||
document.getElementById('wh-name').value = h.name || '';
|
||
document.getElementById('wh-phone').value = h.phone_number || '';
|
||
document.getElementById('wh-template').value = h.message_template || '{{msg}}';
|
||
});
|
||
}
|
||
openModal('webhook-modal');
|
||
}
|
||
|
||
async function saveWebhook() {
|
||
const name = document.getElementById('wh-name').value.trim();
|
||
const phone_number = document.getElementById('wh-phone').value.trim();
|
||
const message_template = document.getElementById('wh-template').value.trim() || '{{msg}}';
|
||
|
||
if (!name || !phone_number) { toast('Name and phone number are required', 'error'); return; }
|
||
|
||
const payload = { name, phone_number, message_template };
|
||
|
||
try {
|
||
if (editingWebhookId) {
|
||
await api('PUT', `/webhooks/${editingWebhookId}`, payload);
|
||
toast('Webhook updated', 'success');
|
||
} else {
|
||
await api('POST', '/webhooks', payload);
|
||
toast('Webhook created', 'success');
|
||
}
|
||
closeModal('webhook-modal');
|
||
loadWebhooks();
|
||
} catch (err) {
|
||
toast('Save failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function toggleWebhook(id, active) {
|
||
try {
|
||
await api('PUT', `/webhooks/${id}`, { active: active ? 1 : 0 });
|
||
} catch (err) {
|
||
toast('Update failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteWebhook(id) {
|
||
if (!confirm('Delete this webhook?')) return;
|
||
try {
|
||
await api('DELETE', `/webhooks/${id}`);
|
||
toast('Webhook deleted', 'success');
|
||
loadWebhooks();
|
||
} catch (err) {
|
||
toast('Delete failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── API Tokens ─────────────────────────────────────────────────────────────
|
||
async function loadTokens() {
|
||
const tbody = document.getElementById('token-list');
|
||
try {
|
||
const tokens = await api('GET', '/tokens');
|
||
if (!tokens.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5"><div class="empty-state"><div class="icon">🔑</div>No API tokens created</div></td></tr>';
|
||
} else {
|
||
tbody.innerHTML = tokens.map(t => `
|
||
<tr>
|
||
<td><strong>${esc(t.name)}</strong></td>
|
||
<td style="color:var(--muted);font-size:12px;font-family:monospace;">••••••••••••••••</td>
|
||
<td>
|
||
<label class="toggle">
|
||
<input type="checkbox" ${t.active ? 'checked' : ''} onchange="App.toggleApiToken(${t.id}, this.checked)" />
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</td>
|
||
<td style="color:var(--muted);font-size:12px;">${fmt(t.created_at)}</td>
|
||
<td>
|
||
<button class="btn btn-sm btn-danger btn-icon" onclick="App.deleteToken(${t.id})" title="Delete">🗑</button>
|
||
</td>
|
||
</tr>`).join('');
|
||
}
|
||
} catch (err) {
|
||
toast('Failed to load tokens: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function openTokenModal() {
|
||
document.getElementById('token-form').style.display = 'block';
|
||
document.getElementById('token-created').style.display = 'none';
|
||
document.getElementById('token-create-btn').style.display = 'inline-flex';
|
||
document.getElementById('tk-name').value = '';
|
||
openModal('token-modal');
|
||
}
|
||
|
||
async function createToken() {
|
||
const name = document.getElementById('tk-name').value.trim();
|
||
if (!name) { toast('Token name is required', 'error'); return; }
|
||
try {
|
||
const tok = await api('POST', '/tokens', { name });
|
||
document.getElementById('new-token-value').textContent = tok.token;
|
||
document.getElementById('token-form').style.display = 'none';
|
||
document.getElementById('token-created').style.display = 'block';
|
||
document.getElementById('token-create-btn').style.display = 'none';
|
||
loadTokens();
|
||
} catch (err) {
|
||
toast('Failed to create token: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function copyToken() {
|
||
const val = document.getElementById('new-token-value').textContent;
|
||
navigator.clipboard?.writeText(val).then(() => toast('Copied to clipboard', 'success'));
|
||
}
|
||
|
||
async function toggleApiToken(id, active) {
|
||
try {
|
||
await api('PATCH', `/tokens/${id}`, { active });
|
||
} catch (err) {
|
||
toast('Update failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function deleteToken(id) {
|
||
if (!confirm('Delete this token? Any apps using it will lose access.')) return;
|
||
try {
|
||
await api('DELETE', `/tokens/${id}`);
|
||
toast('Token deleted', 'success');
|
||
loadTokens();
|
||
} catch (err) {
|
||
toast('Delete failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── Settings ───────────────────────────────────────────────────────────────
|
||
async function loadSettings() {
|
||
try {
|
||
const [cfg, logRes] = await Promise.all([
|
||
api('GET', '/config'),
|
||
api('GET', '/log-level'),
|
||
]);
|
||
document.getElementById('cfg-ip').value = cfg.device_ip || '';
|
||
document.getElementById('cfg-username').value = cfg.device_username || '';
|
||
document.getElementById('cfg-password').value = cfg.device_password || '';
|
||
document.getElementById('cfg-modem').value = cfg.device_modem || '';
|
||
document.getElementById('cfg-poll').value = cfg.poll_interval || '30';
|
||
document.getElementById('cfg-log-level').value = logRes.level || 'INFO';
|
||
} catch (err) {
|
||
toast('Failed to load settings: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveLogLevel() {
|
||
hideAlert('log-alert');
|
||
const level = document.getElementById('cfg-log-level').value;
|
||
try {
|
||
const result = await api('PUT', '/log-level', { level });
|
||
showAlert('log-alert', `Log level set to ${result.level}`, 'success');
|
||
} catch (err) {
|
||
showAlert('log-alert', 'Failed: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function saveConfig() {
|
||
hideAlert('settings-alert');
|
||
const body = {
|
||
device_ip: document.getElementById('cfg-ip').value.trim(),
|
||
device_username: document.getElementById('cfg-username').value.trim(),
|
||
device_password: document.getElementById('cfg-password').value,
|
||
device_modem: document.getElementById('cfg-modem').value.trim(),
|
||
poll_interval: document.getElementById('cfg-poll').value,
|
||
};
|
||
try {
|
||
await api('PUT', '/config', body);
|
||
showAlert('settings-alert', 'Settings saved successfully', 'success');
|
||
toast('Settings saved', 'success');
|
||
} catch (err) {
|
||
showAlert('settings-alert', 'Save failed: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function testConnection() {
|
||
hideAlert('settings-alert');
|
||
showAlert('settings-alert', 'Testing connection…', 'info');
|
||
try {
|
||
const result = await api('POST', '/config/test');
|
||
if (result.success) {
|
||
showAlert('settings-alert', '✅ Connection successful!', 'success');
|
||
} else {
|
||
showAlert('settings-alert', '❌ Connection failed: ' + result.error);
|
||
}
|
||
} catch (err) {
|
||
showAlert('settings-alert', 'Test failed: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function changePassword() {
|
||
hideAlert('pw-alert');
|
||
const currentPassword = document.getElementById('pw-current').value;
|
||
const newPassword = document.getElementById('pw-new').value;
|
||
const confirm = document.getElementById('pw-confirm').value;
|
||
if (newPassword !== confirm) {
|
||
showAlert('pw-alert', 'Passwords do not match');
|
||
return;
|
||
}
|
||
if (newPassword.length < 6) {
|
||
showAlert('pw-alert', 'Password must be at least 6 characters');
|
||
return;
|
||
}
|
||
try {
|
||
await api('PUT', '/auth/password', { currentPassword, newPassword });
|
||
showAlert('pw-alert', 'Password updated successfully', 'success');
|
||
document.getElementById('pw-current').value = '';
|
||
document.getElementById('pw-new').value = '';
|
||
document.getElementById('pw-confirm').value = '';
|
||
} catch (err) {
|
||
showAlert('pw-alert', err.message);
|
||
}
|
||
}
|
||
|
||
// ── Modal helpers ──────────────────────────────────────────────────────────
|
||
function openModal(id) { document.getElementById(id).classList.add('open'); }
|
||
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
function init() {
|
||
// Nav clicks
|
||
document.querySelectorAll('.nav-item').forEach(el => {
|
||
el.addEventListener('click', () => navigate(el.dataset.page));
|
||
});
|
||
|
||
// Login form
|
||
document.getElementById('login-form').addEventListener('submit', login);
|
||
|
||
// Logout
|
||
document.getElementById('logout-btn').addEventListener('click', logout);
|
||
|
||
// Send form
|
||
document.getElementById('send-form').addEventListener('submit', sendSMS);
|
||
document.getElementById('send-message').addEventListener('input', e => {
|
||
document.getElementById('char-count').textContent = e.target.value.length;
|
||
});
|
||
|
||
// Close modal on backdrop click
|
||
document.querySelectorAll('.modal-backdrop').forEach(m => {
|
||
m.addEventListener('click', e => { if (e.target === m) m.classList.remove('open'); });
|
||
});
|
||
|
||
checkAuth();
|
||
}
|
||
|
||
// ── Public API ─────────────────────────────────────────────────────────────
|
||
return {
|
||
init,
|
||
refreshDashboard, loadDashboard,
|
||
triggerPoll,
|
||
openWebhookModal, saveWebhook, toggleWebhook, deleteWebhook, copyWebhookUrl,
|
||
openTokenModal, createToken, copyToken, toggleApiToken, deleteToken,
|
||
saveConfig, testConnection, saveLogLevel, changePassword,
|
||
openModal, closeModal,
|
||
_paginate,
|
||
};
|
||
|
||
})();
|
||
|
||
document.addEventListener('DOMContentLoaded', App.init);
|