653 lines
27 KiB
JavaScript
653 lines
27 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const addForm = document.getElementById('add-form');
|
|
const instancesList = document.getElementById('instances-list');
|
|
const refreshBtn = document.getElementById('refresh-btn');
|
|
const portInput = document.getElementById('port');
|
|
const ufwCmd = document.getElementById('ufw-cmd');
|
|
const iptablesCmd = document.getElementById('iptables-cmd');
|
|
const winCmd = document.getElementById('win-cmd');
|
|
const loginOverlay = document.getElementById('login-overlay');
|
|
const loginForm = document.getElementById('login-form');
|
|
const loginError = document.getElementById('login-error');
|
|
const logoutBtn = document.getElementById('logout-btn');
|
|
const mainNav = document.getElementById('main-nav');
|
|
const navAdmin = document.getElementById('nav-admin');
|
|
const usersList = document.getElementById('users-list');
|
|
const createUserForm = document.getElementById('create-user-form');
|
|
const changePasswordForm = document.getElementById('change-password-form');
|
|
const changeUsernameForm = document.getElementById('change-username-form');
|
|
const profileSuccess = document.getElementById('profile-success');
|
|
const helpBtn = document.getElementById('help-btn');
|
|
const helpModal = document.getElementById('help-modal');
|
|
const addInstanceModal = document.getElementById('add-instance-modal');
|
|
const showAddBtn = document.getElementById('show-add-btn');
|
|
const showAddUserBtn = document.getElementById('show-add-user-btn');
|
|
const addUserModal = document.getElementById('add-user-modal');
|
|
const modalTitle = document.querySelector('#add-instance-modal h2');
|
|
const otpModalTitle = document.getElementById('otp-modal-title');
|
|
const otpSaveBtn = document.getElementById('otp-save-btn');
|
|
const otpSecretForm = document.getElementById('otp-secret-form');
|
|
const showAddOtpBtn = document.getElementById('show-add-otp-btn');
|
|
const addOtpModal = document.getElementById('add-otp-secret-modal');
|
|
const otpSecretIdSelect = document.getElementById('otp-secret-id');
|
|
const otpSecretsList = document.getElementById('otp-secrets-list');
|
|
const logsToggle = document.getElementById('logs-toggle');
|
|
const logsSection = document.getElementById('logs-section');
|
|
const logsViewer = document.getElementById('logs-viewer');
|
|
const logsRefreshBtn = document.getElementById('logs-refresh-btn');
|
|
|
|
let config = { firewall_host_ip: null };
|
|
let logsInterval = null;
|
|
let currentUser = null;
|
|
let editId = null;
|
|
let editOtpId = null;
|
|
let instancesData = [];
|
|
let otpSecretsData = [];
|
|
|
|
const openModal = (modal) => modal.classList.add('active');
|
|
const closeModal = (modal) => {
|
|
modal.classList.remove('active');
|
|
editId = null;
|
|
editOtpId = null;
|
|
if (modal.id === 'add-instance-modal') {
|
|
addForm.reset();
|
|
modalTitle.textContent = 'Add Instance';
|
|
} else if (modal.id === 'add-otp-secret-modal') {
|
|
otpSecretForm.reset();
|
|
otpModalTitle.textContent = 'Add OTP Secret';
|
|
otpSaveBtn.textContent = 'Add Secret';
|
|
document.getElementById('otp-secret').required = true;
|
|
document.getElementById('otp-secret').placeholder = 'Base32 Secret or otpauth:// URL';
|
|
}
|
|
};
|
|
|
|
document.querySelectorAll('.btn-close').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
closeModal(btn.closest('.modal'));
|
|
});
|
|
});
|
|
|
|
window.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('modal')) closeModal(e.target);
|
|
});
|
|
|
|
helpBtn.addEventListener('click', () => openModal(helpModal));
|
|
showAddBtn.addEventListener('click', () => {
|
|
editId = null;
|
|
modalTitle.textContent = 'Add Instance';
|
|
populateOTPSelect();
|
|
openModal(addInstanceModal);
|
|
});
|
|
showAddOtpBtn.addEventListener('click', () => {
|
|
editOtpId = null;
|
|
otpModalTitle.textContent = 'Add OTP Secret';
|
|
otpSaveBtn.textContent = 'Add Secret';
|
|
document.getElementById('otp-secret').required = true;
|
|
document.getElementById('otp-secret').placeholder = 'Base32 Secret or otpauth:// URL';
|
|
openModal(addOtpModal);
|
|
});
|
|
showAddUserBtn.addEventListener('click', () => openModal(addUserModal));
|
|
|
|
const showLogin = () => {
|
|
loginOverlay.style.display = 'flex';
|
|
logoutBtn.style.display = 'none';
|
|
mainNav.style.display = 'none';
|
|
if (logsSection) logsSection.style.display = 'none';
|
|
stopLogsPolling();
|
|
};
|
|
|
|
const hideLogin = () => {
|
|
loginOverlay.style.display = 'none';
|
|
logoutBtn.style.display = 'flex';
|
|
mainNav.style.display = 'flex';
|
|
if (logsToggle && logsToggle.checked) {
|
|
if (logsSection) logsSection.style.display = 'block';
|
|
startLogsPolling();
|
|
}
|
|
};
|
|
|
|
// Tab Switching
|
|
document.querySelectorAll('.nav-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(i => i.classList.remove('active'));
|
|
item.classList.add('active');
|
|
document.getElementById(`${item.dataset.tab}-section`).classList.add('active');
|
|
|
|
if (item.dataset.tab === 'admin') fetchUsers();
|
|
if (item.dataset.tab === 'instances') fetchInstances();
|
|
if (item.dataset.tab === 'otp-secrets') fetchOTPSecrets();
|
|
});
|
|
});
|
|
|
|
const updateFirewallCmds = () => {
|
|
const hostIp = config.firewall_host_ip || window.location.hostname;
|
|
|
|
const loginFirewallIp = document.getElementById('login-firewall-ip');
|
|
if (loginFirewallIp) {
|
|
loginFirewallIp.textContent = hostIp;
|
|
}
|
|
|
|
if (!ufwCmd || !iptablesCmd || !winCmd) return;
|
|
const port = portInput.value || '4646';
|
|
ufwCmd.textContent = `sudo ufw allow from ${hostIp} to any port ${port} proto tcp`;
|
|
iptablesCmd.textContent = `sudo iptables -I INPUT -p tcp -s ${hostIp} --dport ${port} -j ACCEPT`;
|
|
winCmd.textContent = `New-NetFirewallRule -DisplayName "XIVLauncher OTP" -Direction Inbound -RemoteAddress ${hostIp} -LocalPort ${port} -Protocol TCP -Action Allow`;
|
|
};
|
|
|
|
const fetchConfig = async () => {
|
|
try {
|
|
const res = await fetch('/api/config');
|
|
if (res.ok) {
|
|
config = await res.json();
|
|
updateFirewallCmds();
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch config:', e);
|
|
}
|
|
};
|
|
|
|
const fetchInstances = async () => {
|
|
try {
|
|
const res = await fetch('/api/instances');
|
|
if (res.status === 401) return showLogin();
|
|
instancesData = await res.json();
|
|
renderInstances(instancesData);
|
|
} catch (e) {
|
|
console.error('Failed to fetch instances:', e);
|
|
}
|
|
};
|
|
|
|
const fetchOTPSecrets = async () => {
|
|
try {
|
|
const res = await fetch('/api/otp-secrets');
|
|
if (res.status === 401) return showLogin();
|
|
otpSecretsData = await res.json();
|
|
renderOTPSecrets(otpSecretsData);
|
|
} catch (e) {
|
|
console.error('Failed to fetch OTP secrets:', e);
|
|
}
|
|
};
|
|
|
|
const fetchUsers = async () => {
|
|
try {
|
|
const res = await fetch('/api/users');
|
|
if (res.status === 401) return showLogin();
|
|
if (res.ok) {
|
|
const users = await res.json();
|
|
renderUsers(users);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch users:', e);
|
|
}
|
|
};
|
|
|
|
const renderInstances = (instances) => {
|
|
if (instances.length === 0) {
|
|
instancesList.innerHTML = '<div class="empty-state">No instances configured yet.</div>';
|
|
return;
|
|
}
|
|
|
|
instancesList.innerHTML = instances.map(inst => {
|
|
let statusClass = 'status-unknown';
|
|
if (inst.last_status === 'Sent') statusClass = 'status-sent';
|
|
else if (inst.last_status === 'Offline') statusClass = 'status-offline';
|
|
else if (inst.last_status.startsWith('Failed')) statusClass = 'status-offline';
|
|
|
|
const timeAgo = inst.last_checked ? Math.round((Date.now()/1000 - inst.last_checked)) + 's ago' : 'Never';
|
|
|
|
return `
|
|
<div class="instance-item">
|
|
<div class="instance-info">
|
|
<h3>${escapeHtml(inst.name)}</h3>
|
|
<div class="instance-meta">
|
|
<span>${escapeHtml(inst.ip)}:${inst.port}</span>
|
|
<span>OTP: ${escapeHtml(inst.otp_secret_name || 'None')}</span>
|
|
<span class="status-badge ${statusClass}">${escapeHtml(inst.last_status)}</span>
|
|
<span>Last checked: ${timeAgo}</span>
|
|
</div>
|
|
</div>
|
|
<div class="instance-actions">
|
|
<button class="btn btn-icon" onclick="editInstance(${inst.id})" title="Edit">
|
|
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><path d="M6 36l3-9 20-20 6 6-20 20-9 3z" fill="url(#pencilYellow)" stroke="#664d00" stroke-width="1.5"/><path d="M6 36l3-9 6 6z" fill="#e5c158"/><path d="M6 36l1.5-4.5 3 3z" fill="#333"/><path d="M29 7l6 6" stroke="#664d00" stroke-width="1.5"/><path d="M35 13l3.5-3.5a3 3 0 0 0 0-4.2l-1.8-1.8a3 3 0 0 0-4.2 0L29 7z" fill="#ff9999" stroke="#803333" stroke-width="1.5"/></svg>
|
|
</button>
|
|
<button class="delete-btn" onclick="deleteInstance(${inst.id})" title="Delete">
|
|
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="21" fill="url(#redBall)" stroke="#7f1d1d" stroke-width="1.5"/><ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)"/><path d="M16 16l16 16M32 16L16 32" stroke="#ffffff" stroke-width="5" stroke-linecap="round"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
};
|
|
|
|
const renderOTPSecrets = (secrets) => {
|
|
if (secrets.length === 0) {
|
|
otpSecretsList.innerHTML = '<div class="empty-state">No OTP secrets configured yet.</div>';
|
|
return;
|
|
}
|
|
|
|
otpSecretsList.innerHTML = secrets.map(sec => `
|
|
<div class="otp-secret-item">
|
|
<div class="otp-secret-info">
|
|
<h3>${escapeHtml(sec.name)}</h3>
|
|
</div>
|
|
<div class="otp-secret-actions">
|
|
<button class="btn btn-icon" onclick="editOTPSecret(${sec.id})" title="Edit">
|
|
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><path d="M6 36l3-9 20-20 6 6-20 20-9 3z" fill="url(#pencilYellow)" stroke="#664d00" stroke-width="1.5"/><path d="M6 36l3-9 6 6z" fill="#e5c158"/><path d="M6 36l1.5-4.5 3 3z" fill="#333"/><path d="M29 7l6 6" stroke="#664d00" stroke-width="1.5"/><path d="M35 13l3.5-3.5a3 3 0 0 0 0-4.2l-1.8-1.8a3 3 0 0 0-4.2 0L29 7z" fill="#ff9999" stroke="#803333" stroke-width="1.5"/></svg>
|
|
</button>
|
|
<button class="delete-btn" onclick="deleteOTPSecret(${sec.id})" title="Delete">
|
|
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="21" fill="url(#redBall)" stroke="#7f1d1d" stroke-width="1.5"/><ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)"/><path d="M16 16l16 16M32 16L16 32" stroke="#ffffff" stroke-width="5" stroke-linecap="round"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
};
|
|
|
|
const renderUsers = (users) => {
|
|
usersList.innerHTML = users.map(user => `
|
|
<div class="user-item">
|
|
<div class="user-info">
|
|
<h3>${escapeHtml(user.username)} ${user.is_admin ? '<span class="status-badge status-sent">Admin</span>' : ''}</h3>
|
|
</div>
|
|
<div class="user-actions">
|
|
<button class="btn btn-icon" onclick="resetUserPassword(${user.id})" title="Reset Password">
|
|
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><circle cx="15" cy="24" r="9" fill="url(#goldKeyGrad)" stroke="#806600" stroke-width="1.5"/><circle cx="15" cy="24" r="4" fill="#f5f5f5" stroke="#806600" stroke-width="1.5"/><path d="M24 20h18v8h-4v-4h-4v4h-4v-4h-2z" fill="url(#goldKeyGrad)" stroke="#806600" stroke-width="1.5"/></svg>
|
|
</button>
|
|
<button class="delete-btn" onclick="deleteUser(${user.id})" title="Delete User">
|
|
<svg class="icon-oxygen" width="18" height="18" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="21" fill="url(#redBall)" stroke="#7f1d1d" stroke-width="1.5"/><ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)"/><path d="M16 16l16 16M32 16L16 32" stroke="#ffffff" stroke-width="5" stroke-linecap="round"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
};
|
|
|
|
addForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const name = document.getElementById('name').value;
|
|
const ip = document.getElementById('ip').value;
|
|
const port = parseInt(document.getElementById('port').value, 10);
|
|
const otp_secret_id = parseInt(otpSecretIdSelect.value, 10);
|
|
|
|
const url = editId ? `/api/instances/${editId}` : '/api/instances';
|
|
const method = editId ? 'PUT' : 'POST';
|
|
const body = { name, ip, port, otp_secret_id };
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (res.ok) {
|
|
closeModal(addInstanceModal);
|
|
fetchInstances();
|
|
} else if (res.status === 401) {
|
|
showLogin();
|
|
} else {
|
|
alert(`Failed to ${editId ? 'update' : 'add'} instance`);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error saving instance', e);
|
|
}
|
|
});
|
|
|
|
window.editInstance = (id) => {
|
|
const inst = instancesData.find(i => i.id === id);
|
|
if (!inst) return;
|
|
editId = id;
|
|
document.getElementById('name').value = inst.name;
|
|
document.getElementById('ip').value = inst.ip;
|
|
document.getElementById('port').value = inst.port;
|
|
populateOTPSelect(inst.otp_secret_id);
|
|
modalTitle.textContent = 'Edit Instance';
|
|
openModal(addInstanceModal);
|
|
};
|
|
|
|
const populateOTPSelect = (selectedId = null) => {
|
|
otpSecretIdSelect.innerHTML = '<option value="" disabled selected>Select an OTP secret</option>' +
|
|
otpSecretsData.map(sec => `<option value="${sec.id}" ${sec.id === selectedId ? 'selected' : ''}>${escapeHtml(sec.name)}</option>`).join('');
|
|
};
|
|
|
|
otpSecretForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const name = document.getElementById('otp-name').value;
|
|
const secret = document.getElementById('otp-secret').value;
|
|
|
|
const url = editOtpId ? `/api/otp-secrets/${editOtpId}` : '/api/otp-secrets';
|
|
const method = editOtpId ? 'PUT' : 'POST';
|
|
const body = { name };
|
|
if (secret) body.secret = secret;
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (res.ok) {
|
|
closeModal(addOtpModal);
|
|
fetchOTPSecrets();
|
|
} else {
|
|
const data = await res.json();
|
|
alert(data.detail || `Failed to ${editOtpId ? 'update' : 'add'} OTP secret`);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error saving OTP secret', e);
|
|
}
|
|
});
|
|
|
|
window.editOTPSecret = (id) => {
|
|
const sec = otpSecretsData.find(s => s.id === id);
|
|
if (!sec) return;
|
|
editOtpId = id;
|
|
document.getElementById('otp-name').value = sec.name;
|
|
document.getElementById('otp-secret').value = '';
|
|
document.getElementById('otp-secret').required = false;
|
|
document.getElementById('otp-secret').placeholder = 'Leave blank to keep current secret';
|
|
otpModalTitle.textContent = 'Edit OTP Secret';
|
|
otpSaveBtn.textContent = 'Save Changes';
|
|
openModal(addOtpModal);
|
|
};
|
|
|
|
window.deleteOTPSecret = async (id) => {
|
|
if (!confirm('Are you sure you want to delete this OTP secret?')) return;
|
|
try {
|
|
const res = await fetch(`/api/otp-secrets/${id}`, { method: 'DELETE' });
|
|
if (res.status === 401) return showLogin();
|
|
if (res.ok) fetchOTPSecrets();
|
|
else {
|
|
const data = await res.json();
|
|
alert(data.detail || 'Failed to delete OTP secret');
|
|
}
|
|
} catch (e) {
|
|
console.error('Error deleting OTP secret', e);
|
|
}
|
|
};
|
|
|
|
loginForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById('login-username').value;
|
|
const password = document.getElementById('login-password').value;
|
|
try {
|
|
const res = await fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
currentUser = data.user;
|
|
setupUIForUser();
|
|
hideLogin();
|
|
loginError.style.display = 'none';
|
|
document.getElementById('login-password').value = '';
|
|
fetchConfig();
|
|
fetchInstances();
|
|
fetchOTPSecrets();
|
|
} else {
|
|
loginError.style.display = 'block';
|
|
}
|
|
} catch (e) {
|
|
console.error('Login error', e);
|
|
}
|
|
});
|
|
|
|
const setupUIForUser = () => {
|
|
if (currentUser && currentUser.is_admin) {
|
|
navAdmin.style.display = 'block';
|
|
} else {
|
|
navAdmin.style.display = 'none';
|
|
}
|
|
document.getElementById('profile-username').value = currentUser.username;
|
|
};
|
|
|
|
logoutBtn.addEventListener('click', async () => {
|
|
try {
|
|
await fetch('/api/logout');
|
|
currentUser = null;
|
|
showLogin();
|
|
} catch (e) {
|
|
console.error('Logout error', e);
|
|
}
|
|
});
|
|
|
|
changeUsernameForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById('profile-username').value;
|
|
try {
|
|
const res = await fetch('/api/users/me/username', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username })
|
|
});
|
|
if (res.ok) {
|
|
currentUser.username = username;
|
|
profileSuccess.style.display = 'block';
|
|
setTimeout(() => profileSuccess.style.display = 'none', 3000);
|
|
} else {
|
|
const data = await res.json();
|
|
alert(data.detail || 'Failed to update username');
|
|
}
|
|
} catch (e) {
|
|
console.error('Error changing username', e);
|
|
}
|
|
});
|
|
|
|
createUserForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const username = document.getElementById('new-username').value;
|
|
const password = document.getElementById('new-password').value;
|
|
const is_admin = document.getElementById('new-is-admin').checked;
|
|
try {
|
|
const res = await fetch('/api/users', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password, is_admin })
|
|
});
|
|
if (res.ok) {
|
|
createUserForm.reset();
|
|
closeModal(addUserModal);
|
|
fetchUsers();
|
|
} else {
|
|
const data = await res.json();
|
|
alert(data.detail || 'Failed to create user');
|
|
}
|
|
} catch (e) {
|
|
console.error('Error creating user', e);
|
|
}
|
|
});
|
|
|
|
changePasswordForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const password = document.getElementById('profile-new-password').value;
|
|
try {
|
|
const res = await fetch('/api/users/me/password', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password })
|
|
});
|
|
if (res.ok) {
|
|
changePasswordForm.reset();
|
|
profileSuccess.style.display = 'block';
|
|
setTimeout(() => profileSuccess.style.display = 'none', 3000);
|
|
}
|
|
} catch (e) {
|
|
console.error('Error changing password', e);
|
|
}
|
|
});
|
|
|
|
refreshBtn.addEventListener('click', fetchInstances);
|
|
|
|
window.deleteInstance = async (id) => {
|
|
if (!confirm('Are you sure you want to delete this instance?')) return;
|
|
try {
|
|
const res = await fetch(`/api/instances/${id}`, { method: 'DELETE' });
|
|
if (res.status === 401) return showLogin();
|
|
if (res.ok) fetchInstances();
|
|
} catch (e) {
|
|
console.error('Error deleting instance', e);
|
|
}
|
|
};
|
|
|
|
window.deleteUser = async (id) => {
|
|
if (!confirm('Are you sure you want to delete this user? All their instances will also be deleted.')) return;
|
|
try {
|
|
const res = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
|
if (res.status === 401) return showLogin();
|
|
if (res.ok) fetchUsers();
|
|
else {
|
|
const data = await res.json();
|
|
alert(data.detail || 'Failed to delete user');
|
|
}
|
|
} catch (e) {
|
|
console.error('Error deleting user', e);
|
|
}
|
|
};
|
|
|
|
window.resetUserPassword = async (id) => {
|
|
const password = prompt('Enter new password for this user:');
|
|
if (!password) return;
|
|
try {
|
|
const res = await fetch(`/api/users/${id}/password`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password })
|
|
});
|
|
if (res.status === 401) return showLogin();
|
|
if (res.ok) alert('Password updated');
|
|
} catch (e) {
|
|
console.error('Error resetting password', e);
|
|
}
|
|
};
|
|
|
|
window.copyToClipboard = async (elementId) => {
|
|
const text = document.getElementById(elementId).textContent;
|
|
const btn = event.currentTarget;
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
const originalHtml = btn.innerHTML;
|
|
btn.classList.add('copied');
|
|
btn.innerHTML = '<svg class="icon-oxygen" width="14" height="14" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="21" fill="url(#greenBall)" stroke="#0d3c12" stroke-width="1.5"/><ellipse cx="24" cy="11" rx="14" ry="7" fill="url(#glassShineRed)"/><path d="M14 26l7 7 15-15" stroke="#ffffff" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
setTimeout(() => {
|
|
btn.classList.remove('copied');
|
|
btn.innerHTML = originalHtml;
|
|
}, 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy: ', err);
|
|
}
|
|
};
|
|
|
|
portInput.addEventListener('input', updateFirewallCmds);
|
|
|
|
// Bind help button on login overlay
|
|
const loginShowHelpBtn = document.getElementById('login-show-help-btn');
|
|
if (loginShowHelpBtn) {
|
|
loginShowHelpBtn.addEventListener('click', () => openModal(helpModal));
|
|
}
|
|
|
|
// Logging helpers
|
|
const fetchLogs = async () => {
|
|
if (!currentUser) return;
|
|
try {
|
|
const res = await fetch('/api/logs');
|
|
if (res.status === 401) return showLogin();
|
|
if (res.ok) {
|
|
const logs = await res.json();
|
|
renderLogs(logs);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch logs:', e);
|
|
}
|
|
};
|
|
|
|
const startLogsPolling = () => {
|
|
stopLogsPolling();
|
|
fetchLogs();
|
|
logsInterval = setInterval(fetchLogs, 5000);
|
|
};
|
|
|
|
const stopLogsPolling = () => {
|
|
if (logsInterval) {
|
|
clearInterval(logsInterval);
|
|
logsInterval = null;
|
|
}
|
|
};
|
|
|
|
const renderLogs = (logs) => {
|
|
if (!logsViewer) return;
|
|
if (logs.length === 0) {
|
|
logsViewer.innerHTML = '<div style="color: var(--text-muted); text-align: center; padding: 1rem;">No logs available.</div>';
|
|
return;
|
|
}
|
|
|
|
logsViewer.innerHTML = logs.slice().reverse().map(log => {
|
|
const date = new Date(log.timestamp * 1000);
|
|
const timeStr = date.toLocaleTimeString() + '.' + String(date.getMilliseconds()).padStart(3, '0');
|
|
const levelClass = `log-level-${log.level.toLowerCase()}`;
|
|
return `
|
|
<div class="log-entry">
|
|
<span class="log-time">[${timeStr}]</span>
|
|
<span class="log-level ${levelClass}">[${log.level}]</span>
|
|
<span class="log-message">${escapeHtml(log.message)}</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
logsViewer.scrollTop = logsViewer.scrollHeight;
|
|
};
|
|
|
|
if (logsToggle) {
|
|
logsToggle.addEventListener('change', () => {
|
|
if (logsToggle.checked) {
|
|
logsSection.style.display = 'block';
|
|
startLogsPolling();
|
|
} else {
|
|
logsSection.style.display = 'none';
|
|
stopLogsPolling();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (logsRefreshBtn) {
|
|
logsRefreshBtn.addEventListener('click', fetchLogs);
|
|
}
|
|
|
|
const checkAuth = async () => {
|
|
try {
|
|
const res = await fetch('/api/auth/status');
|
|
const data = await res.json();
|
|
if (data.authenticated) {
|
|
currentUser = data.user;
|
|
setupUIForUser();
|
|
hideLogin();
|
|
updateFirewallCmds();
|
|
fetchInstances();
|
|
fetchOTPSecrets();
|
|
} else {
|
|
showLogin();
|
|
}
|
|
} catch (e) {
|
|
showLogin();
|
|
}
|
|
};
|
|
|
|
function escapeHtml(unsafe) {
|
|
if (!unsafe) return '';
|
|
return unsafe.toString()
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
// Unconditionally fetch config on startup to show firewall info on login overlay
|
|
fetchConfig();
|
|
checkAuth();
|
|
|
|
setInterval(() => {
|
|
if (loginOverlay.style.display === 'none' && document.getElementById('instances-section').classList.contains('active')) {
|
|
fetchInstances();
|
|
}
|
|
}, 5000);
|
|
});
|