2583 lines
82 KiB
JavaScript
2583 lines
82 KiB
JavaScript
// WebSSH Client JavaScript
|
|
|
|
let connections = [];
|
|
let currentConnectionId = null;
|
|
let authToken = localStorage.getItem('webssh_token');
|
|
let currentUser = localStorage.getItem('webssh_user');
|
|
let storedUsers = {}; // Will be loaded from /api/config
|
|
let maxUploadSize = localStorage.getItem('maxUploadSize') ? parseInt(localStorage.getItem('maxUploadSize')) : 100 * 1024 * 1024;
|
|
// Dynamically determine API URL based on current host (same origin since everything is in container)
|
|
let apiBaseUrl = window.location.origin;
|
|
let backendWsPort = null;
|
|
let backendWsHost = null;
|
|
let hostStatusTimer = null;
|
|
|
|
// Use the frontend proxy so the browser does not need direct access to the backend port.
|
|
function getWsUrl() {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
return `${protocol}//${window.location.host}/ws`;
|
|
}
|
|
|
|
const CONNECTIONS_KEY = 'webssh_connections';
|
|
|
|
// Load connections from server (with encrypted credentials)
|
|
async function loadSavedConnections() {
|
|
if (!authToken) return;
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/connections`, {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('API response:', data); // DEBUG
|
|
connections = data.connections || [];
|
|
console.log('Loaded connections:', connections); // DEBUG
|
|
// Reset connection states and add UI properties
|
|
connections.forEach(conn => {
|
|
conn.connected = false;
|
|
conn.socket = null;
|
|
conn.loadAverage = null;
|
|
if (!conn.terminalBuffer) conn.terminalBuffer = [];
|
|
});
|
|
renderConnectionList();
|
|
refreshHostStatuses();
|
|
startHostStatusPolling();
|
|
if (connections.length > 0) {
|
|
term.writeln(`[Loaded ${connections.length} connections from server]`);
|
|
}
|
|
} else {
|
|
console.error('API error:', response.status, response.statusText); // DEBUG
|
|
const errorData = await response.text();
|
|
console.error('Error details:', errorData); // DEBUG
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load connections from server:', e);
|
|
// Fallback: try to load non-sensitive data from localStorage
|
|
loadLocalConnections();
|
|
}
|
|
}
|
|
|
|
// Fallback: load only non-sensitive connection info from localStorage
|
|
function loadLocalConnections() {
|
|
const saved = localStorage.getItem(CONNECTIONS_KEY);
|
|
if (saved) {
|
|
try {
|
|
const localConns = JSON.parse(saved);
|
|
// Only use localStorage for connection metadata (no passwords/keys)
|
|
connections = localConns.map(c => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
host: c.host,
|
|
port: c.port,
|
|
username: c.username,
|
|
authType: c.authType,
|
|
password: '', // Don't load sensitive data from localStorage
|
|
key: '',
|
|
connected: false,
|
|
socket: null,
|
|
loadAverage: null,
|
|
terminalBuffer: c.terminalBuffer || []
|
|
}));
|
|
renderConnectionList();
|
|
term.writeln(`[Loaded ${connections.length} connections (no credentials)]`);
|
|
} catch (e) {
|
|
console.error('Failed to load local connections:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save connection to server (with encrypted credentials)
|
|
async function saveConnectionToServer(conn) {
|
|
if (!authToken) return false;
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/connections`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify(conn)
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Save connection error:', response.status, errorText);
|
|
return false;
|
|
}
|
|
|
|
console.log('Connection saved successfully:', conn.name);
|
|
return true;
|
|
} catch (e) {
|
|
console.error('Failed to save connection to server:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Delete connection from server
|
|
async function deleteConnectionFromServer(connId) {
|
|
if (!authToken) return false;
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/connections/delete`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({ id: connId })
|
|
});
|
|
|
|
return response.ok;
|
|
} catch (e) {
|
|
console.error('Failed to delete connection from server:', e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Save non-sensitive metadata to localStorage (for offline mode)
|
|
function saveLocalMetadata() {
|
|
const metadata = connections.map(conn => ({
|
|
id: conn.id,
|
|
name: conn.name,
|
|
host: conn.host,
|
|
port: conn.port,
|
|
username: conn.username,
|
|
authType: conn.authType,
|
|
terminalBuffer: conn.terminalBuffer
|
|
}));
|
|
localStorage.setItem(CONNECTIONS_KEY, JSON.stringify(metadata));
|
|
}
|
|
|
|
// Save connections to localStorage (deprecated - use server storage)
|
|
function saveConnectionsToStorage() {
|
|
// Only save non-sensitive metadata for offline mode
|
|
saveLocalMetadata();
|
|
}
|
|
|
|
// Auto-save when connections change
|
|
function autoSaveConnections() {
|
|
saveLocalMetadata();
|
|
}
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 14,
|
|
theme: { background: '#0b0f14', foreground: '#f3f4f6' },
|
|
fontFamily: 'Consolas, Monaco, monospace'
|
|
});
|
|
|
|
const fitAddon = new FitAddon.FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
term.open(document.getElementById('terminal'));
|
|
|
|
// Debounce function to limit resize calls
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Initial fit after terminal is opened
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
term.refresh(0, term.rows - 1);
|
|
}, 100);
|
|
|
|
// Resize handler with debounce for better performance
|
|
const handleResize = debounce(() => {
|
|
// Store current cursor position and content
|
|
const cursorY = term.buffer.active.cursorY;
|
|
const cursorX = term.buffer.active.cursorX;
|
|
|
|
// Fit terminal to container
|
|
fitAddon.fit();
|
|
|
|
// Refresh terminal to ensure proper rendering
|
|
term.refresh(0, term.rows - 1);
|
|
|
|
// Send resize to backend
|
|
sendResizeForCurrent();
|
|
|
|
// Restore cursor position if within bounds
|
|
if (cursorY < term.rows && cursorX < term.cols) {
|
|
term.buffer.active.cursorY = cursorY;
|
|
term.buffer.active.cursorX = cursorX;
|
|
}
|
|
}, 250);
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
|
|
// Also resize when terminal becomes visible (e.g., after switching from SFTP)
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
handleResize();
|
|
});
|
|
resizeObserver.observe(document.getElementById('terminal'));
|
|
|
|
// Check if already logged in
|
|
if (authToken) {
|
|
verifyToken();
|
|
}
|
|
|
|
// Load saved connections
|
|
loadSavedConnections();
|
|
|
|
// Setup single terminal input handler (called once)
|
|
term.onData((data) => {
|
|
const conn = getCurrentConnection();
|
|
if (conn && conn.socket && conn.socket.readyState === WebSocket.OPEN && conn.connected) {
|
|
conn.socket.send(data);
|
|
}
|
|
});
|
|
|
|
async function login() {
|
|
const username = document.getElementById('loginUsername').value;
|
|
const password = document.getElementById('loginPassword').value;
|
|
const apiUrl = apiBaseUrl;
|
|
|
|
if (!username || !password) {
|
|
document.getElementById('loginError').textContent = 'Please enter username and password';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${apiUrl}/api/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.token) {
|
|
authToken = data.token;
|
|
localStorage.setItem('webssh_token', authToken);
|
|
document.getElementById('loginModal').classList.remove('active');
|
|
document.getElementById('loggedInUser').textContent = data.username;
|
|
term.writeln(`[Authenticated as ${data.username}]`);
|
|
loadSavedConnections();
|
|
setTimeout(loadConfig, 500); // Load configuration after successful login with delay
|
|
} else {
|
|
document.getElementById('loginError').textContent = data.error || 'Login failed';
|
|
}
|
|
} catch (e) {
|
|
document.getElementById('loginError').textContent = 'Connection error: ' + e.message;
|
|
}
|
|
}
|
|
|
|
async function verifyToken() {
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/verify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token: authToken })
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.valid) {
|
|
document.getElementById('loginModal').classList.remove('active');
|
|
document.getElementById('loggedInUser').textContent = data.username;
|
|
term.writeln(`[Authenticated as ${data.username}]`);
|
|
loadSavedConnections(); // Load encrypted credentials from server
|
|
setTimeout(loadConfig, 500); // Load configuration after successful token verification with delay
|
|
} else {
|
|
localStorage.removeItem('webssh_token');
|
|
authToken = null;
|
|
}
|
|
} catch (e) {
|
|
localStorage.removeItem('webssh_token');
|
|
authToken = null;
|
|
}
|
|
}
|
|
|
|
function logout() {
|
|
localStorage.removeItem('webssh_token');
|
|
localStorage.removeItem('webssh_api_url');
|
|
authToken = null;
|
|
location.reload();
|
|
}
|
|
|
|
function generateId() {
|
|
return 'conn_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
}
|
|
|
|
function getCurrentConnection() {
|
|
return connections.find(c => c.id === currentConnectionId);
|
|
}
|
|
|
|
function getConnection(id) {
|
|
return connections.find(c => c.id === id);
|
|
}
|
|
|
|
function showNewConnectionModal() {
|
|
document.getElementById('newConnectionModal').classList.add('active');
|
|
document.getElementById('modalName').focus();
|
|
}
|
|
|
|
function hideModal() {
|
|
document.getElementById('newConnectionModal').classList.remove('active');
|
|
document.getElementById('modalName').value = '';
|
|
document.getElementById('modalHost').value = '';
|
|
document.getElementById('modalPort').value = '22';
|
|
document.getElementById('modalUser').value = '';
|
|
document.getElementById('modalPassword').value = '';
|
|
document.getElementById('modalKey').value = '';
|
|
document.getElementById('modalAuthType').value = 'password';
|
|
toggleModalAuth();
|
|
}
|
|
|
|
function showEditConnectionModal(id) {
|
|
const conn = getConnection(id);
|
|
if (!conn) return;
|
|
|
|
currentConnectionId = id;
|
|
selectConnection(id);
|
|
|
|
document.getElementById('editModalName').value = conn.name || '';
|
|
document.getElementById('editModalHost').value = conn.host || '';
|
|
document.getElementById('editModalPort').value = conn.port || '22';
|
|
document.getElementById('editModalUser').value = conn.username || '';
|
|
document.getElementById('editModalPassword').value = conn.password || '';
|
|
document.getElementById('editModalKey').value = conn.key || '';
|
|
document.getElementById('editModalAuthType').value = conn.authType || 'password';
|
|
document.getElementById('editModalLockTimeout').value = conn.lockTimeout || 15;
|
|
document.getElementById('editModalDisconnectTimeout').value = conn.disconnectTimeout || 60;
|
|
toggleEditModalAuth();
|
|
document.getElementById('editConnectionModal').classList.add('active');
|
|
}
|
|
|
|
function hideEditConnectionModal() {
|
|
document.getElementById('editConnectionModal').classList.remove('active');
|
|
document.getElementById('editModalName').value = '';
|
|
document.getElementById('editModalHost').value = '';
|
|
document.getElementById('editModalPort').value = '22';
|
|
document.getElementById('editModalUser').value = '';
|
|
document.getElementById('editModalPassword').value = '';
|
|
document.getElementById('editModalKey').value = '';
|
|
document.getElementById('editModalAuthType').value = 'password';
|
|
toggleEditModalAuth();
|
|
}
|
|
|
|
function toggleModalAuth() {
|
|
const authType = document.getElementById('modalAuthType').value;
|
|
document.getElementById('modalPasswordGroup').classList.toggle('hidden', authType === 'key');
|
|
document.getElementById('modalKeyGroup').classList.toggle('hidden', authType === 'password');
|
|
}
|
|
|
|
function toggleEditModalAuth() {
|
|
const authType = document.getElementById('editModalAuthType').value;
|
|
document.getElementById('editModalPasswordGroup').classList.toggle('hidden', authType === 'key');
|
|
document.getElementById('editModalKeyGroup').classList.toggle('hidden', authType === 'password');
|
|
}
|
|
|
|
function toggleAuthFields() {
|
|
const authTypeField = document.getElementById('authType');
|
|
const passwordField = document.getElementById('password');
|
|
if (!authTypeField || !passwordField) return;
|
|
|
|
const authType = authTypeField.value;
|
|
passwordField.style.display = authType === 'key' ? 'none' : 'block';
|
|
// Don't auto-show key section - let user control it with toggle button
|
|
const keySection = document.getElementById('keySection');
|
|
if (keySection) keySection.classList.remove('active');
|
|
}
|
|
|
|
function toggleKeyField() {
|
|
const keySection = document.getElementById('keySection');
|
|
const btn = document.getElementById('toggleKeyBtn');
|
|
keySection.classList.toggle('active');
|
|
btn.classList.toggle('active');
|
|
}
|
|
|
|
function createConnection() {
|
|
const lockTimeout = parseInt(document.getElementById('modalLockTimeout').value) || 15;
|
|
const disconnectTimeout = parseInt(document.getElementById('modalDisconnectTimeout').value) || 60;
|
|
|
|
// Validation: disconnect timeout must be greater than lock timeout (unless never)
|
|
if (disconnectTimeout !== 0 && disconnectTimeout <= lockTimeout) {
|
|
alert(`Disconnect timeout (${disconnectTimeout} min) must be greater than Lock timeout (${lockTimeout} min).`);
|
|
return;
|
|
}
|
|
|
|
const conn = {
|
|
id: generateId(),
|
|
name: document.getElementById('modalName').value || 'New Connection',
|
|
host: document.getElementById('modalHost').value,
|
|
port: parseInt(document.getElementById('modalPort').value) || 22,
|
|
username: document.getElementById('modalUser').value,
|
|
authType: document.getElementById('modalAuthType').value,
|
|
password: document.getElementById('modalPassword').value,
|
|
key: document.getElementById('modalKey').value,
|
|
lockTimeout: lockTimeout,
|
|
disconnectTimeout: disconnectTimeout,
|
|
connected: false,
|
|
socket: null,
|
|
loadAverage: null,
|
|
terminalBuffer: []
|
|
};
|
|
|
|
if (!conn.host || !conn.username) { alert('Please enter host and username'); return; }
|
|
if (conn.authType === 'password' && !conn.password) { alert('Please enter password'); return; }
|
|
if (conn.authType === 'key' && !conn.key) { alert('Please paste SSH private key'); return; }
|
|
|
|
connections.push(conn);
|
|
hideModal();
|
|
renderConnectionList();
|
|
autoSaveConnections();
|
|
saveConnectionToServer(conn); // Save encrypted credentials to server
|
|
selectConnection(conn.id);
|
|
}
|
|
|
|
function renderConnectionList() {
|
|
const list = document.getElementById('connectionList');
|
|
list.innerHTML = '';
|
|
connections.forEach(conn => {
|
|
const loadText = conn.connected
|
|
? (conn.loadAverage || 'N/A')
|
|
: (conn.socket && conn.socket.readyState === WebSocket.CONNECTING ? 'Loading...' : '—');
|
|
const item = document.createElement('div');
|
|
item.className = `connection-item ${conn.id === currentConnectionId ? 'active' : ''} ${conn.connected ? 'connected' : 'disconnected'}`;
|
|
item.dataset.connectionId = conn.id;
|
|
item.onclick = () => selectConnection(conn.id);
|
|
item.innerHTML = `
|
|
<div class="connection-heading">
|
|
<div class="connection-title-row">
|
|
<div class="connection-name">${conn.name}</div>
|
|
<div class="host-status ${conn.hostStatus === true ? 'up' : conn.hostStatus === false ? 'down' : 'checking'}">
|
|
Host: ${conn.hostStatus === true ? 'Up' : conn.hostStatus === false ? 'Down' : 'Checking...'}
|
|
</div>
|
|
</div>
|
|
<div class="connection-load" title="Remote Linux load average">Load: ${loadText}</div>
|
|
</div>
|
|
<div class="connection-info">
|
|
<span class="connection-status ${conn.connected ? 'connected' : 'disconnected'}"></span>
|
|
${conn.username}@${conn.host}:${conn.port}
|
|
</div>
|
|
<div class="connection-actions" onclick="event.stopPropagation()">
|
|
${conn.connected
|
|
? `<button class="btn-small btn-disconnect" onclick="disconnectConnection('${conn.id}')">Disconnect</button>`
|
|
: `<button class="btn-small btn-connect" onclick="connectConnection('${conn.id}')">Connect</button>`
|
|
}
|
|
<button class="btn-small btn-edit" onclick="showEditConnectionModal('${conn.id}')">Edit</button>
|
|
<button class="btn-small btn-delete" onclick="deleteConnection('${conn.id}')">Delete</button>
|
|
</div>
|
|
`;
|
|
list.appendChild(item);
|
|
});
|
|
}
|
|
|
|
async function refreshHostStatuses() {
|
|
if (!authToken || connections.length === 0) return;
|
|
|
|
connections.forEach(conn => { conn.hostStatus = null; });
|
|
renderConnectionList();
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/connections/status`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({ ids: connections.map(conn => conn.id) })
|
|
});
|
|
if (!response.ok) return;
|
|
|
|
const data = await response.json();
|
|
connections.forEach(conn => {
|
|
if (Object.prototype.hasOwnProperty.call(data.statuses || {}, conn.id)) {
|
|
conn.hostStatus = data.statuses[conn.id];
|
|
}
|
|
});
|
|
renderConnectionList();
|
|
} catch (e) {
|
|
console.warn('Failed to refresh host statuses:', e);
|
|
}
|
|
}
|
|
|
|
function startHostStatusPolling() {
|
|
if (hostStatusTimer) clearInterval(hostStatusTimer);
|
|
hostStatusTimer = setInterval(refreshHostStatuses, 30000);
|
|
}
|
|
|
|
function selectConnection(id) {
|
|
currentConnectionId = id;
|
|
const conn = getCurrentConnection();
|
|
if (conn) {
|
|
const quickConnectFields = {
|
|
connName: conn.name,
|
|
host: conn.host,
|
|
port: conn.port,
|
|
user: conn.username,
|
|
authType: conn.authType || 'password',
|
|
password: conn.password || '',
|
|
sshKey: conn.key || '',
|
|
lockTimeout: conn.lockTimeout || 15,
|
|
disconnectTimeout: conn.disconnectTimeout || 60
|
|
};
|
|
Object.entries(quickConnectFields).forEach(([fieldId, value]) => {
|
|
const field = document.getElementById(fieldId);
|
|
if (field) field.value = value;
|
|
});
|
|
if (document.getElementById('authType')) toggleAuthFields();
|
|
document.getElementById('terminalTitle').textContent = `${conn.name} - ${conn.username}@${conn.host}:${conn.port}`;
|
|
updateTerminalStatus(conn.connected ? 'connected' : 'disconnected');
|
|
|
|
// Restore terminal buffer for this connection
|
|
term.clear();
|
|
conn.terminalBuffer.forEach(line => term.writeln(line));
|
|
}
|
|
renderConnectionList();
|
|
}
|
|
|
|
function appendToBuffer(connId, data) {
|
|
const conn = getConnection(connId);
|
|
if (conn) {
|
|
const lines = data.split('\n');
|
|
lines.forEach(line => conn.terminalBuffer.push(line));
|
|
// Keep only last 1000 lines
|
|
if (conn.terminalBuffer.length > 1000) {
|
|
conn.terminalBuffer = conn.terminalBuffer.slice(-1000);
|
|
}
|
|
}
|
|
}
|
|
|
|
function saveConnectionSettings() {
|
|
const conn = getCurrentConnection();
|
|
if (!conn) { alert('No connection selected'); return; }
|
|
showEditConnectionModal(conn.id);
|
|
}
|
|
|
|
function saveEditedConnection() {
|
|
const conn = getConnection(currentConnectionId);
|
|
if (!conn) { alert('No connection selected'); return; }
|
|
|
|
const lockTimeout = parseInt(document.getElementById('editModalLockTimeout').value) || 15;
|
|
const disconnectTimeout = parseInt(document.getElementById('editModalDisconnectTimeout').value) || 60;
|
|
|
|
if (disconnectTimeout !== 0 && disconnectTimeout <= lockTimeout) {
|
|
alert(`Disconnect timeout (${disconnectTimeout} min) must be greater than Lock timeout (${lockTimeout} min).`);
|
|
return;
|
|
}
|
|
|
|
conn.name = document.getElementById('editModalName').value || 'New Connection';
|
|
conn.host = document.getElementById('editModalHost').value;
|
|
conn.port = parseInt(document.getElementById('editModalPort').value) || 22;
|
|
conn.username = document.getElementById('editModalUser').value;
|
|
conn.authType = document.getElementById('editModalAuthType').value;
|
|
conn.password = document.getElementById('editModalPassword').value;
|
|
conn.key = document.getElementById('editModalKey').value;
|
|
conn.lockTimeout = lockTimeout;
|
|
conn.disconnectTimeout = disconnectTimeout;
|
|
|
|
if (!conn.host || !conn.username) { alert('Please enter host and username'); return; }
|
|
if (conn.authType === 'password' && !conn.password) { alert('Please enter password'); return; }
|
|
if (conn.authType === 'key' && !conn.key) { alert('Please paste SSH private key'); return; }
|
|
|
|
renderConnectionList();
|
|
selectConnection(conn.id);
|
|
document.getElementById('terminalTitle').textContent = `${conn.name} - ${conn.username}@${conn.host}:${conn.port}`;
|
|
term.writeln('[Settings saved]');
|
|
autoSaveConnections();
|
|
saveConnectionToServer(conn);
|
|
hideEditConnectionModal();
|
|
}
|
|
|
|
function updateTerminalStatus(status) {
|
|
const el = document.getElementById('terminalStatus');
|
|
el.className = `terminal-status ${status}`;
|
|
el.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
|
}
|
|
|
|
function connect() {
|
|
const conn = getCurrentConnection();
|
|
if (conn) {
|
|
connectConnection(conn.id);
|
|
} else {
|
|
// Quick connect - create connection from current form data
|
|
const name = document.getElementById('connName').value || 'Quick Connect';
|
|
const host = document.getElementById('host').value;
|
|
const port = parseInt(document.getElementById('port').value) || 22;
|
|
const username = document.getElementById('user').value;
|
|
const authType = document.getElementById('authType').value;
|
|
const password = document.getElementById('password').value;
|
|
const key = document.getElementById('sshKey').value;
|
|
|
|
if (!host || !username) {
|
|
alert('Please enter host and username, or select/create a connection first');
|
|
return;
|
|
}
|
|
|
|
if (authType === 'password' && !password) {
|
|
alert('Please enter password');
|
|
return;
|
|
}
|
|
|
|
if (authType === 'key' && !key) {
|
|
alert('Please paste SSH private key');
|
|
return;
|
|
}
|
|
|
|
// Create temporary connection
|
|
const newConn = {
|
|
id: generateId(),
|
|
name: name,
|
|
host: host,
|
|
port: port,
|
|
username: username,
|
|
authType: authType,
|
|
password: password,
|
|
key: key,
|
|
connected: false,
|
|
socket: null,
|
|
terminalBuffer: []
|
|
};
|
|
|
|
connections.push(newConn);
|
|
renderConnectionList();
|
|
autoSaveConnections();
|
|
saveConnectionToServer(newConn); // Save to server
|
|
selectConnection(newConn.id);
|
|
connectConnection(newConn.id);
|
|
}
|
|
}
|
|
|
|
async function connectConnection(id) {
|
|
const conn = getConnection(id);
|
|
if (!conn) { alert('Connection not found'); return; }
|
|
|
|
// Don't disconnect other connections - allow multiple!
|
|
if (id !== currentConnectionId) {
|
|
selectConnection(id);
|
|
}
|
|
|
|
if (conn.socket && conn.socket.readyState === WebSocket.OPEN) {
|
|
term.writeln('[Already connected]');
|
|
return;
|
|
}
|
|
|
|
term.writeln(`Connecting to ${conn.name} (${conn.username}@${conn.host}:${conn.port})...`);
|
|
updateTerminalStatus('connecting');
|
|
conn.loadAverage = null;
|
|
renderConnectionList();
|
|
|
|
try {
|
|
if (!backendWsPort) {
|
|
await loadConfig();
|
|
}
|
|
const wsUrl = getWsUrl();
|
|
const wsUrlWithToken = wsUrl + '?token=' + encodeURIComponent(authToken || '');
|
|
conn.socket = new WebSocket(wsUrlWithToken);
|
|
|
|
conn.socket.onopen = () => {
|
|
// Generate session ID for recording
|
|
const sessionId = generateSessionId();
|
|
conn.sessionId = sessionId;
|
|
|
|
const authData = {
|
|
host: conn.host, port: conn.port, username: conn.username,
|
|
password: conn.authType === 'password' ? conn.password : undefined,
|
|
key: conn.authType === 'key' ? conn.key : undefined,
|
|
session_id: sessionId,
|
|
connection_id: conn.id,
|
|
connection_name: conn.name, // Connection name for display
|
|
cols: term.cols,
|
|
rows: term.rows
|
|
};
|
|
conn.socket.send(JSON.stringify(authData));
|
|
};
|
|
|
|
conn.socket.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === 'connected') {
|
|
conn.connected = true;
|
|
if (conn.id === currentConnectionId) {
|
|
updateTerminalStatus('connected');
|
|
term.writeln('[Connected]');
|
|
sendResizeForConnection(conn);
|
|
}
|
|
renderConnectionList();
|
|
// Start inactivity timers for auto-lock
|
|
resetInactivityTimers();
|
|
return;
|
|
}
|
|
if (msg.type === 'load_average') {
|
|
conn.loadAverage = msg.value || null;
|
|
renderConnectionList();
|
|
return;
|
|
}
|
|
if (msg.type === 'error') {
|
|
if (conn.id === currentConnectionId) {
|
|
term.writeln(`[ERROR] ${msg.message}`);
|
|
updateTerminalStatus('disconnected');
|
|
}
|
|
conn.connected = false;
|
|
conn.loadAverage = null;
|
|
renderConnectionList();
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
// Not JSON, it's terminal data
|
|
if (conn.id === currentConnectionId) {
|
|
term.write(event.data);
|
|
}
|
|
appendToBuffer(conn.id, event.data);
|
|
}
|
|
};
|
|
|
|
conn.socket.onerror = () => {
|
|
if (conn.id === currentConnectionId) {
|
|
term.writeln('[ERROR] WebSocket connection failed');
|
|
updateTerminalStatus('disconnected');
|
|
}
|
|
conn.connected = false;
|
|
conn.loadAverage = null;
|
|
renderConnectionList();
|
|
};
|
|
|
|
conn.socket.onclose = (event) => {
|
|
conn.connected = false;
|
|
conn.loadAverage = null;
|
|
conn.socket = null;
|
|
if (conn.id === currentConnectionId) {
|
|
term.writeln(`[DISCONNECTED] Code: ${event.code}`);
|
|
updateTerminalStatus('disconnected');
|
|
}
|
|
renderConnectionList();
|
|
};
|
|
|
|
} catch (e) {
|
|
if (conn.id === currentConnectionId) {
|
|
term.writeln(`[ERROR] Failed to create WebSocket: ${e.message}`);
|
|
updateTerminalStatus('disconnected');
|
|
}
|
|
}
|
|
}
|
|
|
|
function disconnect() {
|
|
const conn = getCurrentConnection();
|
|
if (conn && conn.socket) {
|
|
conn.socket.close();
|
|
}
|
|
}
|
|
|
|
function disconnectConnection(id) {
|
|
const conn = getConnection(id);
|
|
if (conn && conn.socket) {
|
|
conn.socket.close();
|
|
}
|
|
}
|
|
|
|
async function deleteConnection(id) {
|
|
if (!confirm('Delete this connection?')) return;
|
|
|
|
const conn = getConnection(id);
|
|
if (!await deleteConnectionFromServer(id)) {
|
|
alert('Failed to delete connection from server. Please try again.');
|
|
return;
|
|
}
|
|
|
|
if (conn && conn.socket) conn.socket.close();
|
|
connections = connections.filter(c => c.id !== id);
|
|
autoSaveConnections();
|
|
|
|
if (currentConnectionId === id) {
|
|
currentConnectionId = null;
|
|
term.clear();
|
|
document.getElementById('terminalTitle').textContent = 'Terminal';
|
|
updateTerminalStatus('disconnected');
|
|
['connName','host','port','user','password','sshKey'].forEach(id => document.getElementById(id).value = '');
|
|
document.getElementById('port').value = '22';
|
|
}
|
|
|
|
renderConnectionList();
|
|
}
|
|
|
|
function sendResizeForCurrent() {
|
|
const conn = getCurrentConnection();
|
|
if (conn) sendResizeForConnection(conn);
|
|
}
|
|
|
|
function sendResizeForConnection(conn) {
|
|
if (conn && conn.socket && conn.connected) {
|
|
conn.socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
toggleAuthFields();
|
|
term.writeln('WebSSH Multi-Connection Client');
|
|
term.writeln('Click "+ New Connection" to get started');
|
|
|
|
// ============================================
|
|
// SFTP File Manager Functions
|
|
// Global variables for SFTP
|
|
let sftpSocket = null;
|
|
let sftpCurrentPath = '.';
|
|
let sftpSelectedFile = null;
|
|
let sftpSelectedFileIsDir = false;
|
|
let sftpUploadQueue = [];
|
|
let currentSftpConnection = null;
|
|
// Load configuration from API
|
|
function loadConfig() {
|
|
console.log('loadConfig() called, token exists:', !!localStorage.getItem('webssh_token'));
|
|
console.log('apiBaseUrl:', apiBaseUrl);
|
|
|
|
fetch(`${apiBaseUrl}/api/config`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('webssh_token')}`
|
|
}
|
|
})
|
|
.then(response => {
|
|
console.log('loadConfig() response status:', response.status);
|
|
if (response.ok) {
|
|
return response.json();
|
|
} else {
|
|
console.log('Failed to load config, response:', response.status);
|
|
return null;
|
|
}
|
|
})
|
|
.then(config => {
|
|
console.log('loadConfig() config:', config);
|
|
if (config && config.config) {
|
|
// Load max upload size
|
|
if (config.config.max_upload_size) {
|
|
maxUploadSize = config.config.max_upload_size;
|
|
localStorage.setItem('maxUploadSize', maxUploadSize);
|
|
console.log(`Loaded max upload size: ${formatBytes(maxUploadSize)} (${maxUploadSize} bytes)`);
|
|
}
|
|
if (config.config.ws_port) {
|
|
backendWsPort = config.config.ws_port;
|
|
backendWsHost = config.config.ws_host || window.location.hostname || 'localhost';
|
|
console.log(`Loaded backend WebSocket port: ${backendWsPort}`);
|
|
}
|
|
// Load stored users for lock screen verification
|
|
if (config.config.stored_users) {
|
|
storedUsers = config.config.stored_users;
|
|
console.log('Loaded stored users for lock screen');
|
|
}
|
|
// Load current user
|
|
if (config.config.current_user) {
|
|
currentUser = config.config.current_user;
|
|
}
|
|
} else {
|
|
console.log('No config data found');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.warn('Failed to load config:', error);
|
|
});
|
|
|
|
// Log current maxUploadSize for debugging
|
|
console.log('Current maxUploadSize: ' + maxUploadSize + ' bytes (' + (maxUploadSize / 1024 / 1024).toFixed(1) + 'MB)');
|
|
}
|
|
|
|
// Load config when page loads
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Wait a bit for token to be available
|
|
setTimeout(loadConfig, 1000);
|
|
});
|
|
|
|
// Local file system variables
|
|
let localCurrentPath = [];
|
|
let localSelectedFile = null;
|
|
let localCurrentItems = []; // Store current directory items
|
|
let localDirectoryHandle = null; // Added missing variable
|
|
|
|
async function sftpChooseLocalFolder() {
|
|
try {
|
|
if (window.showDirectoryPicker) {
|
|
const dirHandle = await window.showDirectoryPicker();
|
|
const structure = await sftpBuildDirectoryStructureFromHandle(dirHandle);
|
|
localDirectoryHandle = {
|
|
type: 'fs_access',
|
|
rootHandle: dirHandle,
|
|
structure: structure,
|
|
currentPath: []
|
|
};
|
|
|
|
const pathInput = document.getElementById('sftpLocalPathInput');
|
|
if (pathInput) {
|
|
pathInput.value = structure.name;
|
|
}
|
|
|
|
sftpRenderLocalDirectory(structure, []);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to pick directory via showDirectoryPicker:', e);
|
|
}
|
|
|
|
// Fallback: use webkitdirectory input (still requires user selection)
|
|
sftpBrowseLocalFolder();
|
|
}
|
|
|
|
async function sftpBuildDirectoryStructureFromHandle(dirHandle) {
|
|
const root = { name: dirHandle.name || 'Local', type: 'directory', children: {}, handle: dirHandle };
|
|
|
|
for await (const entry of dirHandle.values()) {
|
|
if (entry.kind === 'directory') {
|
|
root.children[entry.name] = await sftpBuildDirectoryStructureFromHandle(entry);
|
|
} else {
|
|
root.children[entry.name] = {
|
|
name: entry.name,
|
|
type: 'file',
|
|
handle: entry
|
|
};
|
|
}
|
|
}
|
|
|
|
return root;
|
|
}
|
|
|
|
// Local File Browser Functions - Cross-browser compatible
|
|
function sftpBrowseLocalFolder() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
// Fallback for browsers without showDirectoryPicker (e.g. Firefox)
|
|
// Try multiple directory-picking attributes for broader compatibility.
|
|
input.setAttribute('webkitdirectory', '');
|
|
input.setAttribute('directory', '');
|
|
input.setAttribute('mozdirectory', '');
|
|
input.multiple = true;
|
|
|
|
input.onchange = (e) => {
|
|
if (e.target.files.length > 0) {
|
|
const files = Array.from(e.target.files);
|
|
if (files[0].webkitRelativePath) {
|
|
const structure = buildDirectoryStructure(files);
|
|
sftpShowLocalDirectoryStructure(structure, files);
|
|
} else {
|
|
sftpShowLocalFilesFlat(files);
|
|
}
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
async function sftpShowLocalFilesFlat(files) {
|
|
const container = document.getElementById('sftpLocalFiles');
|
|
if (!container) return;
|
|
|
|
// Store for later use
|
|
localDirectoryHandle = {
|
|
type: 'flat',
|
|
allFiles: files
|
|
};
|
|
|
|
// Update path
|
|
const pathInput = document.getElementById('sftpLocalPathInput');
|
|
if (pathInput) {
|
|
pathInput.value = 'Local Files';
|
|
}
|
|
|
|
// Column headers
|
|
let html = `
|
|
<div class="sftp-column-headers">
|
|
<span class="col-name">Filename</span>
|
|
<span class="col-size">Size</span>
|
|
<span class="col-date">Date</span>
|
|
</div>
|
|
`;
|
|
|
|
files.forEach(file => {
|
|
const icon = getFileIcon(file.name);
|
|
const size = formatBytes(file.size || 0);
|
|
const date = file.lastModified ? new Date(file.lastModified).toLocaleString() : '';
|
|
|
|
html += `<div class="sftp-file-item"
|
|
onclick="sftpSelectLocalFlatFile(this, '${escapeHtml(file.name).replace(/'/g, "\\'")}')"
|
|
data-filename="${escapeHtml(file.name).replace(/'/g, "\\'")}">
|
|
<span class="icon">${icon}</span>
|
|
<span class="name">${escapeHtml(file.name)}</span>
|
|
<span class="size">${size}</span>
|
|
<span class="date">${date}</span>
|
|
</div>`;
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function sftpSelectLocalFlatFile(element, name) {
|
|
localSelectedFile = name;
|
|
document.querySelectorAll('#sftpLocalFiles .sftp-file-item').forEach(el => el.classList.remove('selected'));
|
|
element.classList.add('selected');
|
|
}
|
|
|
|
function sftpHandleLocalFolderDragOver(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
document.getElementById('sftpLocalFiles').classList.add('drag-over');
|
|
}
|
|
|
|
async function sftpHandleLocalFolderDrop(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const items = e.dataTransfer.items;
|
|
if (!items || items.length === 0) return;
|
|
|
|
// Check if dropped item is a folder
|
|
const item = items[0];
|
|
if (item.kind === 'file') {
|
|
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
|
if (entry && entry.isDirectory) {
|
|
// It's a folder - traverse it
|
|
traverseDirectory(entry, (files) => {
|
|
if (files.length > 0) {
|
|
const structure = buildDirectoryStructure(files);
|
|
sftpShowLocalDirectoryStructure(structure, files);
|
|
}
|
|
});
|
|
} else {
|
|
// It's a file - just show it
|
|
const files = Array.from(e.dataTransfer.files);
|
|
sftpShowLocalFilesFlat(files);
|
|
}
|
|
}
|
|
}
|
|
|
|
function traverseDirectory(entry, callback) {
|
|
const files = [];
|
|
|
|
function readEntries(dirReader) {
|
|
dirReader.readEntries((entries) => {
|
|
if (entries.length === 0) {
|
|
callback(files);
|
|
return;
|
|
}
|
|
|
|
let count = entries.length;
|
|
|
|
entries.forEach((entry) => {
|
|
if (entry.isFile) {
|
|
entry.file((file) => {
|
|
// Add relative path info
|
|
const relativePath = entry.fullPath.substring(1); // Remove leading /
|
|
Object.defineProperty(file, 'webkitRelativePath', {
|
|
value: relativePath,
|
|
writable: false
|
|
});
|
|
files.push(file);
|
|
count--;
|
|
if (count === 0) readEntries(dirReader);
|
|
});
|
|
} else if (entry.isDirectory) {
|
|
readEntries(entry.createReader());
|
|
count--;
|
|
if (count === 0) readEntries(dirReader);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
if (entry.isDirectory) {
|
|
readEntries(entry.createReader());
|
|
} else {
|
|
entry.file((file) => {
|
|
files.push(file);
|
|
callback(files);
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildDirectoryStructure(files) {
|
|
const root = { name: 'Home', type: 'directory', children: {} };
|
|
|
|
files.forEach(file => {
|
|
const pathParts = file.webkitRelativePath.split('/');
|
|
let current = root;
|
|
|
|
pathParts.forEach((part, index) => {
|
|
if (index === pathParts.length - 1) {
|
|
// File
|
|
current.children[part] = {
|
|
name: part,
|
|
type: 'file',
|
|
file: file,
|
|
size: file.size,
|
|
lastModified: file.lastModified
|
|
};
|
|
} else {
|
|
// Directory
|
|
if (!current.children[part]) {
|
|
current.children[part] = {
|
|
name: part,
|
|
type: 'directory',
|
|
children: {}
|
|
};
|
|
}
|
|
current = current.children[part];
|
|
}
|
|
});
|
|
});
|
|
|
|
return root;
|
|
}
|
|
|
|
function sftpShowLocalDirectoryStructure(structure, allFiles) {
|
|
const container = document.getElementById('sftpLocalFiles');
|
|
if (!container) return;
|
|
|
|
// Store for later use
|
|
localDirectoryHandle = {
|
|
type: 'fallback',
|
|
structure: structure,
|
|
currentPath: [],
|
|
allFiles: allFiles
|
|
};
|
|
|
|
// Update path
|
|
const pathInput = document.getElementById('sftpLocalPathInput');
|
|
if (pathInput) {
|
|
pathInput.value = structure.name;
|
|
}
|
|
|
|
sftpRenderLocalDirectory(structure, []);
|
|
}
|
|
|
|
async function sftpRenderLocalDirectory(dir, path) {
|
|
const container = document.getElementById('sftpLocalFiles');
|
|
if (!container) return;
|
|
|
|
const children = Object.values(dir.children || {});
|
|
const dirs = children.filter(c => c.type === 'directory').sort((a, b) => a.name.localeCompare(b.name));
|
|
const files = children.filter(c => c.type === 'file').sort((a, b) => a.name.localeCompare(b.name));
|
|
const sorted = [...dirs, ...files];
|
|
|
|
// Column headers
|
|
let html = `
|
|
<div class="sftp-column-headers">
|
|
<span class="col-name">Filename</span>
|
|
<span class="col-size">Size</span>
|
|
<span class="col-date">Date</span>
|
|
</div>
|
|
`;
|
|
|
|
// Parent directory
|
|
if (path.length > 0) {
|
|
html += `<div class="sftp-file-item" onclick="sftpNavigateLocalStructureUp()">
|
|
<span class="icon">📁</span>
|
|
<span class="name">..</span>
|
|
<span class="size"></span>
|
|
<span class="date"></span>
|
|
</div>`;
|
|
}
|
|
|
|
// Get file sizes and dates for File System Access API files
|
|
for (const item of sorted) {
|
|
const icon = item.type === 'directory' ? '📁' : getFileIcon(item.name);
|
|
|
|
// Get file size - for File System Access API, we need to get the file handle and query its size
|
|
let size = item.type === 'directory' ? '' : formatBytes(item.size || 0);
|
|
let date = item.lastModified ? new Date(item.lastModified).toLocaleString() : '';
|
|
|
|
// For File System Access API files, try to get actual file size and date
|
|
if (item.type === 'file' && item.handle && !item.size) {
|
|
try {
|
|
const file = await item.handle.getFile();
|
|
item.size = file.size; // Cache the size for future use
|
|
size = formatBytes(file.size);
|
|
console.log(`Got file size for ${item.name}: ${file.size} bytes`);
|
|
} catch (error) {
|
|
console.warn(`Could not get file size for ${item.name}:`, error);
|
|
}
|
|
}
|
|
|
|
// Also get file modification date if missing
|
|
if (item.type === 'file' && item.handle && !item.lastModified) {
|
|
try {
|
|
const file = await item.handle.getFile();
|
|
item.lastModified = file.lastModified;
|
|
date = new Date(file.lastModified).toLocaleString();
|
|
console.log(`Got file date for ${item.name}: ${file.lastModified}`);
|
|
} catch (error) {
|
|
console.warn(`Could not get file date for ${item.name}:`, error);
|
|
}
|
|
}
|
|
|
|
const pathStr = path.concat(item.name).join('/');
|
|
|
|
if (item.type === 'directory') {
|
|
html += `<div class="sftp-file-item"
|
|
onclick="sftpOpenLocalStructureDir('${escapeHtml(pathStr).replace(/'/g, "\\'")}')">
|
|
<span class="icon">${icon}</span>
|
|
<span class="name">${escapeHtml(item.name)}</span>
|
|
<span class="size">${size}</span>
|
|
<span class="date">${date}</span>
|
|
</div>`;
|
|
} else {
|
|
html += `<div class="sftp-file-item"
|
|
onclick="sftpSelectLocalStructureFile(this, '${escapeHtml(item.name).replace(/'/g, "\\'")}')"
|
|
data-path="${escapeHtml(pathStr).replace(/'/g, "\\'")}">
|
|
<span class="icon">${icon}</span>
|
|
<span class="name">${escapeHtml(item.name)}</span>
|
|
<span class="size">${size}</span>
|
|
<span class="date">${date}</span>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function sftpOpenLocalStructureDir(pathStr) {
|
|
if (!localDirectoryHandle || !localDirectoryHandle.structure) return;
|
|
|
|
console.log('sftpOpenLocalStructureDir called with path:', pathStr);
|
|
console.log('Current structure:', localDirectoryHandle.structure);
|
|
|
|
const parts = pathStr.split('/');
|
|
let current = localDirectoryHandle.structure;
|
|
|
|
for (const part of parts) {
|
|
if (part === '') continue; // Skip empty parts from leading/trailing slashes
|
|
|
|
if (current.children && current.children[part]) {
|
|
current = current.children[part];
|
|
console.log('Found part:', part, 'current:', current);
|
|
} else {
|
|
console.log('Part not found:', part, 'in current:', current);
|
|
return;
|
|
}
|
|
}
|
|
|
|
localDirectoryHandle.currentPath = parts;
|
|
|
|
// Update path display
|
|
const pathInput = document.getElementById('sftpLocalPathInput');
|
|
if (pathInput) {
|
|
const fullPath = [localDirectoryHandle.structure.name, ...parts].join('/');
|
|
pathInput.value = fullPath;
|
|
}
|
|
|
|
console.log('Calling sftpRenderLocalDirectory with:', current, parts);
|
|
sftpRenderLocalDirectory(current, parts);
|
|
}
|
|
|
|
function sftpNavigateLocalStructureUp() {
|
|
if (!localDirectoryHandle || !localDirectoryHandle.structure) return;
|
|
|
|
const path = localDirectoryHandle.currentPath;
|
|
if (path.length === 0) return;
|
|
|
|
path.pop();
|
|
|
|
let current = localDirectoryHandle.structure;
|
|
for (const part of path) {
|
|
if (current.children && current.children[part]) {
|
|
current = current.children[part];
|
|
}
|
|
}
|
|
|
|
localDirectoryHandle.currentPath = path;
|
|
|
|
// Update path display
|
|
const pathInput = document.getElementById('sftpLocalPathInput');
|
|
if (pathInput) {
|
|
const fullPath = [localDirectoryHandle.structure.name, ...path].join('/');
|
|
pathInput.value = fullPath;
|
|
}
|
|
|
|
sftpRenderLocalDirectory(current, path);
|
|
}
|
|
|
|
function sftpSelectLocalStructureFile(element, name) {
|
|
localSelectedFile = name;
|
|
document.querySelectorAll('#sftpLocalFiles .sftp-file-item').forEach(el => el.classList.remove('selected'));
|
|
element.classList.add('selected');
|
|
}
|
|
|
|
async function sftpUploadFromLocal() {
|
|
// Check if we have a file selected from structure view
|
|
const selectedEl = document.querySelector('#sftpLocalFiles .sftp-file-item.selected');
|
|
if (selectedEl) {
|
|
// Check for path-based selection (directory structure mode)
|
|
if (selectedEl.dataset.path) {
|
|
const path = selectedEl.dataset.path;
|
|
const file = await findFileInStructure(path);
|
|
if (file) {
|
|
// Check file size before upload
|
|
if (file.size > maxUploadSize) {
|
|
// Show error in Transfer Queue
|
|
const queueId = sftpAddToQueue(file.name, file.size, 'upload');
|
|
sftpUpdateQueueProgress(queueId, 0, 'Failed: File too large. Max size: ' + formatBytes(maxUploadSize));
|
|
return;
|
|
}
|
|
sftpUploadFiles([file]);
|
|
return;
|
|
}
|
|
}
|
|
// Check for filename-based selection (flat file mode)
|
|
if (selectedEl.dataset.filename) {
|
|
const filename = selectedEl.dataset.filename;
|
|
if (localDirectoryHandle && localDirectoryHandle.allFiles) {
|
|
const file = localDirectoryHandle.allFiles.find(f => f.name === filename);
|
|
if (file) {
|
|
// Check file size before upload
|
|
if (file.size > maxUploadSize) {
|
|
alert(`File "${file.name}" is too large for upload.\n\nSize: ${formatBytes(file.size)}\nMaximum size: ${formatBytes(maxUploadSize)}\n\nPlease select a smaller file.`);
|
|
return;
|
|
}
|
|
sftpUploadFiles([file]);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// No file selected in UI, so open a picker for direct upload
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.multiple = true;
|
|
input.onchange = (e) => {
|
|
if (e.target.files.length > 0) {
|
|
sftpUploadFiles(e.target.files);
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
function findFileInStructure(path) {
|
|
if (!localDirectoryHandle) return null;
|
|
|
|
if (localDirectoryHandle.type === 'fs_access') {
|
|
const parts = path.split('/').filter(Boolean);
|
|
let current = localDirectoryHandle.structure;
|
|
for (const part of parts) {
|
|
if (!current.children || !current.children[part]) return null;
|
|
current = current.children[part];
|
|
}
|
|
if (current.type !== 'file' || !current.handle) return null;
|
|
return current.handle.getFile();
|
|
}
|
|
|
|
if (!localDirectoryHandle.allFiles) return null;
|
|
|
|
// Find file by matching path (webkitdirectory mode)
|
|
return localDirectoryHandle.allFiles.find(f => f.webkitRelativePath === path);
|
|
}
|
|
|
|
function toggleSftpView() {
|
|
const terminalView = document.getElementById('terminalView');
|
|
const sftpView = document.getElementById('sftpView');
|
|
const sftpToggleBtn = document.getElementById('sftpToggleBtn');
|
|
|
|
if (window.getComputedStyle(sftpView).display === 'none') {
|
|
// Show SFTP, hide terminal
|
|
const conn = getCurrentConnection() || connections.find(connection =>
|
|
connection.id === document.querySelector('.connection-item.active')?.dataset.connectionId
|
|
);
|
|
if (!conn) {
|
|
alert('Please select a connection first');
|
|
return;
|
|
}
|
|
|
|
if (currentConnectionId !== conn.id) {
|
|
selectConnection(conn.id);
|
|
}
|
|
|
|
currentSftpConnection = conn;
|
|
terminalView.style.display = 'none';
|
|
sftpView.style.display = 'flex';
|
|
sftpToggleBtn.classList.add('active');
|
|
document.getElementById('terminalToggleBtn').classList.remove('active');
|
|
|
|
// Connect SFTP
|
|
sftpConnect(conn);
|
|
} else {
|
|
// Show terminal, hide SFTP
|
|
terminalView.style.display = 'flex';
|
|
sftpView.style.display = 'none';
|
|
sftpToggleBtn.classList.remove('active');
|
|
document.getElementById('terminalToggleBtn').classList.add('active');
|
|
}
|
|
}
|
|
|
|
function hideSftpPanel() {
|
|
// Hide SFTP view and show terminal
|
|
const terminalView = document.getElementById('terminalView');
|
|
const sftpView = document.getElementById('sftpView');
|
|
const sftpToggleBtn = document.getElementById('sftpToggleBtn');
|
|
|
|
terminalView.style.display = 'flex';
|
|
sftpView.style.display = 'none';
|
|
sftpToggleBtn.classList.remove('active');
|
|
document.getElementById('terminalToggleBtn').classList.add('active');
|
|
}
|
|
|
|
function showSftpPanel() {
|
|
// Legacy function - delegates to toggleSftpView
|
|
const sftpView = document.getElementById('sftpView');
|
|
if (sftpView.style.display === 'none') {
|
|
toggleSftpView();
|
|
}
|
|
}
|
|
|
|
function sftpConnect(conn) {
|
|
currentSftpConnection = conn;
|
|
// Directly list the root directory
|
|
sftpList('.');
|
|
}
|
|
|
|
async function sftpList(path) {
|
|
if (!currentSftpConnection) return;
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/sftp/list`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
conn_id: currentSftpConnection.id,
|
|
path: path
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to list directory');
|
|
}
|
|
|
|
const data = await response.json();
|
|
sftpRenderFiles(data.files, data.path);
|
|
|
|
} catch (err) {
|
|
console.error('List error:', err);
|
|
document.getElementById('sftpRemoteFiles').innerHTML = '<div class="sftp-empty">Failed to load directory</div>';
|
|
}
|
|
}
|
|
|
|
function sftpRenderFiles(files, path) {
|
|
sftpCurrentPath = path;
|
|
|
|
// Update path input
|
|
const pathInput = document.getElementById('sftpRemotePathInput');
|
|
if (pathInput) {
|
|
pathInput.value = path === '.' ? '/' : path;
|
|
}
|
|
|
|
const container = document.getElementById('sftpRemoteFiles');
|
|
if (!container) return;
|
|
|
|
if (files.length === 0) {
|
|
container.innerHTML = '<div class="sftp-empty">Empty directory</div>';
|
|
return;
|
|
}
|
|
|
|
// Column headers
|
|
let html = `
|
|
<div class="sftp-column-headers">
|
|
<span class="col-name">Filename</span>
|
|
<span class="col-size">Size</span>
|
|
<span class="col-date">Date</span>
|
|
</div>
|
|
`;
|
|
|
|
// Parent directory entry
|
|
if (path !== '.' && path !== '/') {
|
|
html += `<div class="sftp-file-item" onclick="sftpNavigateUp()">
|
|
<span class="icon">📁</span>
|
|
<span class="name">..</span>
|
|
<span class="size"></span>
|
|
<span class="date"></span>
|
|
</div>`;
|
|
}
|
|
|
|
files.forEach(file => {
|
|
const icon = file.is_dir ? '📁' : getFileIcon(file.name);
|
|
const size = file.is_dir ? '' : formatBytes(file.size);
|
|
const date = new Date(file.mtime * 1000).toLocaleString();
|
|
|
|
html += `<div class="sftp-file-item ${sftpSelectedFile === file.name ? 'selected' : ''}"
|
|
onclick="sftpSelectFile('${escapeHtml(file.name).replace(/'/g, "\\'")}', ${file.is_dir})"
|
|
ondblclick="sftpOpenFile('${escapeHtml(file.name).replace(/'/g, "\\'")}', ${file.is_dir})">
|
|
<span class="icon">${icon}</span>
|
|
<span class="name">${escapeHtml(file.name)}</span>
|
|
<span class="size">${size}</span>
|
|
<span class="date">${date}</span>
|
|
</div>`;
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
|
|
// Update info in header
|
|
const dirs = files.filter(f => f.is_dir).length;
|
|
const files_ = files.filter(f => !f.is_dir).length;
|
|
const infoEl = document.getElementById('sftpRemoteInfo');
|
|
if (infoEl) {
|
|
infoEl.textContent = `${dirs} dirs, ${files_} files`;
|
|
}
|
|
}
|
|
|
|
function sftpHandlePathKey(event) {
|
|
if (event.key === 'Enter') {
|
|
const path = event.target.value;
|
|
sftpList(path);
|
|
}
|
|
}
|
|
|
|
function sftpUploadSelected() {
|
|
// Use the new local file browser function
|
|
sftpUploadFromLocal();
|
|
}
|
|
|
|
async function sftpDownloadSelected() {
|
|
if (!sftpSelectedFile) {
|
|
alert('Please select a file to download');
|
|
return;
|
|
}
|
|
|
|
if (sftpSelectedFileIsDir) {
|
|
alert('Cannot download directories. Please select individual files.');
|
|
return;
|
|
}
|
|
|
|
await sftpDownload(sftpSelectedFile);
|
|
}
|
|
|
|
function sftpClearCompleted() {
|
|
const queueList = document.getElementById('sftpQueueList');
|
|
if (queueList) {
|
|
queueList.innerHTML = '<div class="sftp-queue-empty">No transfers in queue</div>';
|
|
}
|
|
}
|
|
|
|
// Updated upload progress for queue
|
|
function sftpAddToQueue(filename, size, type) {
|
|
const queueList = document.getElementById('sftpQueueList');
|
|
if (!queueList) return;
|
|
|
|
// Remove empty message if present
|
|
const emptyMsg = queueList.querySelector('.sftp-queue-empty');
|
|
if (emptyMsg) emptyMsg.remove();
|
|
|
|
const itemId = 'queue-' + Date.now();
|
|
const item = document.createElement('div');
|
|
item.id = itemId;
|
|
item.className = 'sftp-queue-item';
|
|
item.innerHTML = `
|
|
<span class="queue-icon">${type === 'upload' ? '⬆️' : '⬇️'}</span>
|
|
<span class="queue-name">${escapeHtml(filename)}</span>
|
|
<div class="queue-progress">
|
|
<div class="queue-progress-bar" style="width: 0%"></div>
|
|
</div>
|
|
<span class="queue-status">Queued</span>
|
|
<span class="queue-speed">${formatBytes(size)}</span>
|
|
`;
|
|
|
|
queueList.appendChild(item);
|
|
return itemId;
|
|
}
|
|
|
|
function sftpUpdateQueueProgress(itemId, progress, status) {
|
|
const item = document.getElementById(itemId);
|
|
if (!item) return;
|
|
|
|
const bar = item.querySelector('.queue-progress-bar');
|
|
const statusEl = item.querySelector('.queue-status');
|
|
|
|
if (bar) bar.style.width = progress + '%';
|
|
if (statusEl) statusEl.textContent = status;
|
|
|
|
if (progress >= 100) {
|
|
item.classList.add('completed');
|
|
if (statusEl) statusEl.textContent = 'Complete';
|
|
}
|
|
}
|
|
|
|
function sftpHandleLocalDrop(e) {
|
|
e.preventDefault();
|
|
const files = e.dataTransfer.files;
|
|
if (files.length > 0) {
|
|
sftpUploadFiles(files);
|
|
}
|
|
}
|
|
|
|
function getFileIcon(filename) {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const icons = {
|
|
'txt': '📄', 'md': '📝', 'json': '📋', 'js': '📜', 'py': '🐍',
|
|
'html': '🌐', 'css': '🎨', 'xml': '📋', 'yml': '📋', 'yaml': '📋',
|
|
'sh': '⚡', 'bash': '⚡', 'zsh': '⚡', 'fish': '⚡',
|
|
'zip': '📦', 'tar': '📦', 'gz': '📦', 'bz2': '📦', '7z': '📦',
|
|
'jpg': '🖼️', 'jpeg': '🖼️', 'png': '🖼️', 'gif': '🖼️', 'svg': '🖼️',
|
|
'mp4': '🎬', 'avi': '🎬', 'mkv': '🎬', 'mov': '🎬',
|
|
'mp3': '🎵', 'wav': '🎵', 'flac': '🎵', 'ogg': '🎵',
|
|
'pdf': '📕', 'doc': '📘', 'docx': '📘', 'xls': '📗', 'xlsx': '📗',
|
|
'exe': '⚙️', 'bin': '⚙️', 'app': '⚙️'
|
|
};
|
|
return icons[ext] || '📄';
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function sftpSelectFile(name, isDir) {
|
|
sftpSelectedFile = name;
|
|
sftpSelectedFileIsDir = isDir;
|
|
document.querySelectorAll('.sftp-file-item').forEach(el => el.classList.remove('selected'));
|
|
event.currentTarget.classList.add('selected');
|
|
}
|
|
|
|
function sftpOpenFile(name, isDir) {
|
|
if (isDir) {
|
|
const newPath = sftpCurrentPath === '.' ? name : sftpCurrentPath + '/' + name;
|
|
sftpList(newPath);
|
|
} else {
|
|
sftpDownload(name);
|
|
}
|
|
}
|
|
|
|
function sftpNavigateUp() {
|
|
if (sftpCurrentPath === '.' || sftpCurrentPath === '/') return;
|
|
const parts = sftpCurrentPath.split('/');
|
|
parts.pop();
|
|
const newPath = parts.length === 0 ? '.' : parts.join('/');
|
|
sftpList(newPath);
|
|
}
|
|
|
|
function sftpRefresh() {
|
|
sftpList(sftpCurrentPath);
|
|
}
|
|
|
|
async function sftpRefreshLocalFiles() {
|
|
// Refresh local file list based on current mode
|
|
if (localDirectoryHandle) {
|
|
if (localDirectoryHandle.type === 'fs_access') {
|
|
// File System Access API mode - refresh only current directory
|
|
console.log('Refreshing File System Access API directory...');
|
|
console.log('Current path:', localDirectoryHandle.currentPath);
|
|
|
|
// Navigate to current directory and rebuild only its contents
|
|
let currentDir = localDirectoryHandle.rootHandle;
|
|
if (localDirectoryHandle.currentPath.length > 0) {
|
|
console.log('Navigating to path:', localDirectoryHandle.currentPath);
|
|
for (const pathPart of localDirectoryHandle.currentPath) {
|
|
currentDir = await currentDir.getDirectoryHandle(pathPart);
|
|
}
|
|
}
|
|
|
|
// Build structure only for current directory
|
|
const structure = {
|
|
name: currentDir.name || 'current',
|
|
type: 'directory',
|
|
children: {}
|
|
};
|
|
|
|
console.log('Rebuilding structure for current directory:', currentDir.name);
|
|
|
|
for await (const [name, handle] of currentDir.entries()) {
|
|
if (handle.kind === 'directory') {
|
|
structure.children[name] = {
|
|
name: name,
|
|
type: 'directory',
|
|
handle: handle
|
|
};
|
|
} else {
|
|
structure.children[name] = {
|
|
name: name,
|
|
type: 'file',
|
|
handle: handle,
|
|
webkitRelativePath: name
|
|
};
|
|
}
|
|
}
|
|
|
|
console.log('New structure:', structure);
|
|
|
|
// Update only the current directory in the hierarchy
|
|
if (localDirectoryHandle.currentPath.length === 0) {
|
|
// We're at root, replace the entire structure
|
|
localDirectoryHandle.structure = structure;
|
|
} else {
|
|
// We're in a subdirectory, update only this directory in the hierarchy
|
|
let parent = localDirectoryHandle.structure;
|
|
for (let i = 0; i < localDirectoryHandle.currentPath.length - 1; i++) {
|
|
const pathPart = localDirectoryHandle.currentPath[i];
|
|
parent = parent.children[pathPart];
|
|
}
|
|
// Update the current directory
|
|
const currentDirName = localDirectoryHandle.currentPath[localDirectoryHandle.currentPath.length - 1];
|
|
parent.children[currentDirName] = structure;
|
|
}
|
|
|
|
// Re-render current directory
|
|
sftpOpenLocalStructureDir(localDirectoryHandle.currentPath.join('/'));
|
|
|
|
} else if (localDirectoryHandle.type === 'flat') {
|
|
// Flat file mode - just show the same files (they're already loaded)
|
|
console.log('Refreshing flat file mode');
|
|
sftpShowLocalFilesFlat(localDirectoryHandle.allFiles);
|
|
} else if (localDirectoryHandle.type === 'fallback') {
|
|
// Fallback mode - refresh current directory
|
|
console.log('Refreshing fallback mode');
|
|
sftpOpenLocalStructureDir(localDirectoryHandle.currentPath.join('/'));
|
|
}
|
|
} else {
|
|
console.log('No local directory handle available');
|
|
}
|
|
}
|
|
|
|
async function sftpMkdir() {
|
|
const name = prompt('Enter folder name:');
|
|
if (!name) return;
|
|
const path = sftpCurrentPath === '.' ? name : sftpCurrentPath + '/' + name;
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/sftp/mkdir`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
conn_id: currentSftpConnection.id,
|
|
path: path
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to create directory');
|
|
}
|
|
|
|
// Refresh the directory listing
|
|
sftpRefresh();
|
|
|
|
} catch (err) {
|
|
console.error('Mkdir error:', err);
|
|
alert('Failed to create folder: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function sftpDelete() {
|
|
if (!sftpSelectedFile) {
|
|
alert('Please select a file or folder first');
|
|
return;
|
|
}
|
|
const isDir = document.querySelector('.sftp-file-item.selected .icon').textContent === '📁';
|
|
if (!confirm(`Delete ${sftpSelectedFile}?`)) return;
|
|
const path = sftpCurrentPath === '.' ? sftpSelectedFile : sftpCurrentPath + '/' + sftpSelectedFile;
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/sftp/rm`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
conn_id: currentSftpConnection.id,
|
|
path: path,
|
|
is_dir: isDir
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to delete');
|
|
}
|
|
|
|
// Refresh the directory listing
|
|
sftpRefresh();
|
|
|
|
} catch (err) {
|
|
console.error('Delete error:', err);
|
|
alert('Failed to delete: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function sftpRename() {
|
|
if (!sftpSelectedFile) {
|
|
alert('Please select a file or folder first');
|
|
return;
|
|
}
|
|
const newName = prompt('Enter new name:', sftpSelectedFile);
|
|
if (!newName || newName === sftpSelectedFile) return;
|
|
|
|
const srcPath = sftpCurrentPath === '.' ? sftpSelectedFile : sftpCurrentPath + '/' + sftpSelectedFile;
|
|
const dstPath = sftpCurrentPath === '.' ? newName : sftpCurrentPath + '/' + newName;
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/sftp/mv`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
conn_id: currentSftpConnection.id,
|
|
src: srcPath,
|
|
dst: dstPath
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to rename');
|
|
}
|
|
|
|
// Refresh the directory listing
|
|
sftpRefresh();
|
|
|
|
} catch (err) {
|
|
console.error('Rename error:', err);
|
|
alert('Failed to rename: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function sftpDownload(filename) {
|
|
if (!currentSftpConnection) return;
|
|
|
|
const remotePath = sftpCurrentPath === '.' ? filename : sftpCurrentPath + '/' + filename;
|
|
|
|
console.log('Attempting download:', { filename, remotePath, connId: currentSftpConnection.id });
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/sftp/download`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({
|
|
conn_id: currentSftpConnection.id,
|
|
path: remotePath
|
|
})
|
|
});
|
|
|
|
console.log('Download response status:', response.status);
|
|
console.log('Download response headers:', response.headers);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error('Download failed with response:', errorText);
|
|
throw new Error(`Download failed: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
console.log('Download blob size:', blob.size, 'type:', blob.type);
|
|
|
|
// Try to save directly to the open local directory using File System Access API
|
|
if (localDirectoryHandle && localDirectoryHandle.type === 'fs_access' && localDirectoryHandle.rootHandle) {
|
|
try {
|
|
console.log('Attempting to save to local directory...');
|
|
// Get the current directory handle
|
|
let currentDir = localDirectoryHandle.rootHandle;
|
|
if (localDirectoryHandle.currentPath.length > 0) {
|
|
for (const pathPart of localDirectoryHandle.currentPath) {
|
|
currentDir = await currentDir.getDirectoryHandle(pathPart);
|
|
}
|
|
}
|
|
|
|
// Create/get the file handle and write the content
|
|
const fileHandle = await currentDir.getFileHandle(filename, { create: true });
|
|
const writable = await fileHandle.createWritable();
|
|
await writable.write(blob);
|
|
await writable.close();
|
|
|
|
console.log('Successfully saved to local directory');
|
|
|
|
// Show success message
|
|
const queueId = sftpAddToQueue(filename, blob.size, 'download');
|
|
sftpUpdateQueueProgress(queueId, 100, 'Complete');
|
|
|
|
// Refresh local file list to show the newly downloaded file
|
|
console.log('About to call sftpRefreshLocalFiles()...');
|
|
try {
|
|
await sftpRefreshLocalFiles();
|
|
console.log('sftpRefreshLocalFiles() completed successfully');
|
|
} catch (error) {
|
|
console.error('Error in sftpRefreshLocalFiles():', error);
|
|
}
|
|
|
|
return;
|
|
} catch (fsError) {
|
|
console.error('Failed to write to local directory:', fsError);
|
|
// Fall back to browser download if File System Access fails
|
|
}
|
|
}
|
|
|
|
// Fallback: traditional browser download
|
|
console.log('Using fallback browser download');
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
a.remove();
|
|
|
|
} catch (err) {
|
|
console.error('Download error:', err);
|
|
alert('Download failed: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// Drag & Drop Upload
|
|
function sftpHandleDragOver(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
const dropZone = document.getElementById('sftpRemoteFiles');
|
|
if (dropZone) dropZone.classList.add('drag-over');
|
|
}
|
|
|
|
function sftpHandleDrop(e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const dropZone = document.getElementById('sftpRemoteFiles');
|
|
if (dropZone) dropZone.classList.remove('drag-over');
|
|
|
|
const files = e.dataTransfer.files;
|
|
if (files.length > 0) {
|
|
sftpUploadFiles(files);
|
|
}
|
|
}
|
|
|
|
function sftpHandleFileSelect(e) {
|
|
const files = e.target.files;
|
|
if (files.length > 0) {
|
|
sftpUploadFiles(files);
|
|
}
|
|
}
|
|
|
|
async function sftpUploadFiles(files) {
|
|
if (!currentSftpConnection) {
|
|
alert('No connection selected');
|
|
return;
|
|
}
|
|
|
|
for (const file of Array.from(files)) {
|
|
const remotePath = sftpCurrentPath === '.' ? file.name : sftpCurrentPath + '/' + file.name;
|
|
const queueId = sftpAddToQueue(file.name, file.size, 'upload');
|
|
|
|
try {
|
|
// Create FormData for multipart upload
|
|
const formData = new FormData();
|
|
formData.append('conn_id', currentSftpConnection.id);
|
|
formData.append('remote_path', remotePath);
|
|
formData.append('file', file);
|
|
|
|
// Update queue status
|
|
sftpUpdateQueueProgress(queueId, 0, 'Starting...');
|
|
|
|
const response = await fetch(`${apiBaseUrl}/api/sftp/upload`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Upload failed');
|
|
}
|
|
|
|
// Update queue to complete
|
|
sftpUpdateQueueProgress(queueId, 100, 'Complete');
|
|
|
|
// Refresh remote directory
|
|
sftpRefresh();
|
|
|
|
} catch (err) {
|
|
console.error('Upload error:', err);
|
|
sftpUpdateQueueProgress(queueId, 0, 'Failed: ' + err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Help functions
|
|
function showHelp() {
|
|
document.getElementById('helpModal').classList.add('active');
|
|
}
|
|
|
|
function hideHelp() {
|
|
document.getElementById('helpModal').classList.remove('active');
|
|
}
|
|
|
|
// Session ID generator for recordings
|
|
function generateSessionId() {
|
|
return 'sess_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
}
|
|
|
|
// Session Recording Functions
|
|
let currentPlayer = null;
|
|
let currentRecording = null;
|
|
let currentConnectionRecordings = null; // Store recordings for current connection view
|
|
|
|
function showRecordings() {
|
|
document.getElementById('recordingsModal').classList.add('active');
|
|
loadConnectionsWithRecordings();
|
|
}
|
|
|
|
function hideRecordings() {
|
|
document.getElementById('recordingsModal').classList.remove('active');
|
|
currentConnectionRecordings = null;
|
|
}
|
|
|
|
function hidePlayer() {
|
|
document.getElementById('playerModal').classList.remove('active');
|
|
if (currentPlayer) {
|
|
currentPlayer.dispose();
|
|
currentPlayer = null;
|
|
}
|
|
}
|
|
|
|
// Load connections that have recordings (grouped view)
|
|
async function loadConnectionsWithRecordings() {
|
|
const container = document.getElementById('recordingsList');
|
|
container.innerHTML = '<p class="text-muted">Loading connections...</p>';
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/recordings/connections`, {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load connections');
|
|
}
|
|
|
|
const data = await response.json();
|
|
renderConnectionsWithRecordings(data.connections || []);
|
|
} catch (err) {
|
|
console.error('Error loading connections:', err);
|
|
container.innerHTML = `<p class="text-muted">Error: ${err.message}</p>`;
|
|
}
|
|
}
|
|
|
|
// Render connections list with recordings count
|
|
function renderConnectionsWithRecordings(connections) {
|
|
const container = document.getElementById('recordingsList');
|
|
|
|
if (connections.length === 0) {
|
|
container.innerHTML = '<p class="text-muted">No recordings found</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="recordings-header">
|
|
<button class="btn-back" onclick="loadConnectionsWithRecordings()" style="display:none;" id="backToConnectionsBtn">
|
|
← Back to Connections
|
|
</button>
|
|
<span class="recordings-title">Connections with Recordings</span>
|
|
</div>
|
|
<div class="connections-list">
|
|
${connections.map(conn => {
|
|
const totalSize = conn.total_size_bytes ? formatBytes(conn.total_size_bytes) : '0 B';
|
|
const lastRecording = conn.last_recording ? new Date(conn.last_recording).toLocaleDateString() : 'N/A';
|
|
const displayName = conn.connection_name || conn.host;
|
|
|
|
return `
|
|
<div class="connection-recordings-item" onclick="showConnectionRecordings('${conn.connection_id}')">
|
|
<div class="connection-recordings-info">
|
|
<div class="connection-recordings-name">${displayName} (${conn.ssh_username})</div>
|
|
<div class="connection-recordings-meta">
|
|
${conn.recording_count} recordings • ${totalSize} • Last: ${lastRecording}
|
|
</div>
|
|
</div>
|
|
<div class="connection-recordings-actions" onclick="event.stopPropagation();">
|
|
<button class="btn-recording-action btn-download" onclick="downloadAllRecordings('${conn.connection_id}', '${displayName}')" title="Download All">⬇ All</button>
|
|
<button class="btn-recording-action btn-delete-rec" onclick="deleteAllRecordings('${conn.connection_id}', '${displayName}')" title="Delete All">🗑 All</button>
|
|
<span class="expand-icon">▶</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Show recordings for a specific connection
|
|
async function showConnectionRecordings(connectionId) {
|
|
const container = document.getElementById('recordingsList');
|
|
container.innerHTML = '<p class="text-muted">Loading recordings...</p>';
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/recordings?connection_id=${connectionId}`, {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load recordings');
|
|
}
|
|
|
|
const data = await response.json();
|
|
currentConnectionRecordings = data.recordings || [];
|
|
renderConnectionRecordings(currentConnectionRecordings, connectionId);
|
|
} catch (err) {
|
|
console.error('Error loading recordings:', err);
|
|
container.innerHTML = `<p class="text-muted">Error: ${err.message}</p>`;
|
|
}
|
|
}
|
|
|
|
// Render recordings for a single connection
|
|
function renderConnectionRecordings(recordings, connectionId) {
|
|
const container = document.getElementById('recordingsList');
|
|
|
|
if (recordings.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="recordings-header">
|
|
<button class="btn-back" onclick="loadConnectionsWithRecordings()">← Back to Connections</button>
|
|
</div>
|
|
<p class="text-muted">No recordings found for this connection</p>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Get connection info from first recording
|
|
const firstRec = recordings[0];
|
|
const displayName = firstRec.connection_name || firstRec.host;
|
|
|
|
container.innerHTML = `
|
|
<div class="recordings-header">
|
|
<button class="btn-back" onclick="loadConnectionsWithRecordings()">← Back to Connections</button>
|
|
<div class="connection-actions-bar">
|
|
<button class="btn-recording-action btn-download" onclick="downloadAllRecordings('${connectionId}', '${displayName}')">⬇ Download All (${recordings.length})</button>
|
|
<button class="btn-recording-action btn-delete-rec" onclick="deleteAllRecordings('${connectionId}', '${displayName}')">🗑 Delete All (${recordings.length})</button>
|
|
</div>
|
|
</div>
|
|
<div class="connection-recordings-title">${displayName} - ${recordings.length} Recordings</div>
|
|
<div class="recordings-list">
|
|
${recordings.map(rec => {
|
|
const duration = rec.duration_seconds ? formatDuration(rec.duration_seconds) : 'N/A';
|
|
const size = rec.file_size_bytes ? formatBytes(rec.file_size_bytes) : 'N/A';
|
|
const date = new Date(rec.started_at).toLocaleString();
|
|
|
|
return `
|
|
<div class="recording-item">
|
|
<div class="recording-info">
|
|
<div class="recording-host">${date}</div>
|
|
<div class="recording-meta">
|
|
${duration} • ${size}
|
|
</div>
|
|
</div>
|
|
<div class="recording-actions">
|
|
<button class="btn-recording-action btn-play" onclick="playRecording('${rec.id}')">▶ Play</button>
|
|
<button class="btn-recording-action btn-download" onclick="downloadRecording('${rec.id}')">⬇ Download</button>
|
|
<button class="btn-recording-action btn-delete-rec" onclick="deleteRecording('${rec.id}')">🗑 Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Download all recordings for a connection as zip
|
|
async function downloadAllRecordings(connectionId, connectionName) {
|
|
if (!confirm(`Download all recordings for ${connectionName} as a zip file?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/recordings/download-all?connection_id=${connectionId}`, {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to download recordings');
|
|
}
|
|
|
|
// Get the blob and create download link
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `${connectionName}_recordings.zip`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
} catch (err) {
|
|
console.error('Error downloading all recordings:', err);
|
|
alert('Failed to download recordings: ' + err.message);
|
|
}
|
|
}
|
|
|
|
// Delete all recordings for a connection
|
|
async function deleteAllRecordings(connectionId, connectionName) {
|
|
if (!confirm(`Are you sure you want to delete ALL recordings for ${connectionName}?\n\nThis action cannot be undone!`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/recordings/delete-all`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({ connection_id: connectionId })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to delete recordings');
|
|
}
|
|
|
|
const result = await response.json();
|
|
alert(`Deleted ${result.deleted_count} recordings successfully.`);
|
|
|
|
// Go back to connections list
|
|
loadConnectionsWithRecordings();
|
|
|
|
} catch (err) {
|
|
console.error('Error deleting all recordings:', err);
|
|
alert('Failed to delete recordings: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function playRecording(recordingId) {
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/recordings/${recordingId}`, {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to load recording');
|
|
}
|
|
|
|
const data = await response.json();
|
|
currentRecording = data.recording;
|
|
|
|
// Show player modal
|
|
document.getElementById('playerModal').classList.add('active');
|
|
|
|
// Update recording info
|
|
const infoDiv = document.getElementById('recordingInfo');
|
|
const duration = currentRecording.duration_seconds ? formatDuration(currentRecording.duration_seconds) : 'N/A';
|
|
const displayName = currentRecording.connection_name || currentRecording.host;
|
|
infoDiv.innerHTML = `
|
|
<div class="recording-info-panel">
|
|
<h4>${displayName} (${currentRecording.ssh_username})</h4>
|
|
<p>Started: ${new Date(currentRecording.started_at).toLocaleString()}</p>
|
|
<p>Duration: ${duration} • Size: ${formatBytes(currentRecording.file_size_bytes || 0)}</p>
|
|
</div>
|
|
`;
|
|
|
|
// Load and play recording
|
|
const playerContainer = document.getElementById('asciinemaPlayer');
|
|
playerContainer.innerHTML = '<p class="text-muted">Loading recording...</p>';
|
|
|
|
// Download the recording file
|
|
const fileResponse = await fetch(`${apiBaseUrl}/api/recordings/${recordingId}/download`, {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (!fileResponse.ok) {
|
|
throw new Error('Failed to download recording file');
|
|
}
|
|
|
|
// Get the array buffer and decompress with pako
|
|
const compressedData = await fileResponse.arrayBuffer();
|
|
const decompressedData = pako.ungzip(new Uint8Array(compressedData));
|
|
const castContent = new TextDecoder().decode(decompressedData);
|
|
|
|
// Create a blob with the decompressed content
|
|
const blob = new Blob([castContent], { type: 'application/x-asciicast' });
|
|
const recordingUrl = URL.createObjectURL(blob);
|
|
|
|
playerContainer.innerHTML = '';
|
|
|
|
// Create asciinema player
|
|
currentPlayer = AsciinemaPlayer.create(recordingUrl, playerContainer, {
|
|
autoPlay: true,
|
|
loop: false,
|
|
theme: 'monokai',
|
|
fit: 'width',
|
|
terminalFontSize: '14px'
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('Error playing recording:', err);
|
|
alert('Failed to play recording: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function downloadRecording(recordingId) {
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/recordings/${recordingId}`, {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to get recording info');
|
|
}
|
|
|
|
const data = await response.json();
|
|
const rec = data.recording;
|
|
|
|
// Create download link
|
|
const downloadUrl = `${apiBaseUrl}/api/recordings/${recordingId}/download`;
|
|
const link = document.createElement('a');
|
|
link.href = downloadUrl;
|
|
link.download = `${rec.host}_${rec.session_id}.cast.gz`;
|
|
link.setAttribute('data-authorization', `Bearer ${authToken}`);
|
|
|
|
// Set auth header for download
|
|
fetch(downloadUrl, {
|
|
headers: { 'Authorization': `Bearer ${authToken}` }
|
|
})
|
|
.then(response => response.blob())
|
|
.then(blob => {
|
|
const url = window.URL.createObjectURL(blob);
|
|
link.href = url;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(url);
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('Error downloading recording:', err);
|
|
alert('Failed to download recording: ' + err.message);
|
|
}
|
|
}
|
|
|
|
async function deleteRecording(recordingId) {
|
|
if (!confirm('Are you sure you want to delete this recording?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${apiBaseUrl}/api/recordings/delete`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${authToken}`
|
|
},
|
|
body: JSON.stringify({ recording_id: recordingId })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to delete recording');
|
|
}
|
|
|
|
// Reload recordings list
|
|
loadRecordings();
|
|
|
|
} catch (err) {
|
|
console.error('Error deleting recording:', err);
|
|
alert('Failed to delete recording: ' + err.message);
|
|
}
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (seconds < 60) return `${seconds}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
}
|
|
|
|
// ==================== AUTO-LOCK & TIMEOUT ====================
|
|
|
|
let inactivityTimer = null;
|
|
let lockTimer = null;
|
|
let lastActivityTime = Date.now();
|
|
let isLocked = false;
|
|
let currentLockTimeout = 15; // minutes
|
|
let currentDisconnectTimeout = 60; // minutes
|
|
|
|
// Track user activity
|
|
function trackActivity() {
|
|
lastActivityTime = Date.now();
|
|
|
|
// If locked, don't reset timers
|
|
if (isLocked) return;
|
|
|
|
// Reset timers
|
|
resetInactivityTimers();
|
|
}
|
|
|
|
// Setup activity listeners
|
|
function setupActivityTracking() {
|
|
// Track mouse/keyboard activity on document
|
|
document.addEventListener('mousemove', trackActivity, { passive: true });
|
|
document.addEventListener('keydown', trackActivity, { passive: true });
|
|
document.addEventListener('click', trackActivity, { passive: true });
|
|
document.addEventListener('touchstart', trackActivity, { passive: true });
|
|
|
|
// Track terminal input
|
|
if (term) {
|
|
term.onData(() => trackActivity());
|
|
}
|
|
|
|
// Start inactivity check interval
|
|
setInterval(checkInactivity, 60000); // Check every minute
|
|
}
|
|
|
|
// Reset inactivity timers
|
|
function resetInactivityTimers() {
|
|
// Clear existing timers
|
|
if (lockTimer) clearTimeout(lockTimer);
|
|
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
|
|
const conn = getCurrentConnection();
|
|
if (!conn || !conn.connected) return;
|
|
|
|
const lockTimeoutMs = (conn.lockTimeout || currentLockTimeout) * 60 * 1000;
|
|
const disconnectTimeoutMs = conn.disconnectTimeout === 0 ? 0 : (conn.disconnectTimeout || currentDisconnectTimeout) * 60 * 1000;
|
|
|
|
// Set lock timer
|
|
lockTimer = setTimeout(() => {
|
|
lockSession();
|
|
}, lockTimeoutMs);
|
|
|
|
// Set disconnect timer (only if not "never" = 0)
|
|
if (disconnectTimeoutMs > 0) {
|
|
inactivityTimer = setTimeout(() => {
|
|
autoDisconnect();
|
|
}, disconnectTimeoutMs);
|
|
}
|
|
}
|
|
|
|
// Check inactivity (backup in case timers fail)
|
|
function checkInactivity() {
|
|
if (isLocked) return;
|
|
|
|
const conn = getCurrentConnection();
|
|
if (!conn || !conn.connected) return;
|
|
|
|
const elapsedMinutes = (Date.now() - lastActivityTime) / 1000 / 60;
|
|
const lockTimeout = conn.lockTimeout || currentLockTimeout;
|
|
const disconnectTimeout = conn.disconnectTimeout || currentDisconnectTimeout;
|
|
|
|
if (elapsedMinutes >= lockTimeout && !isLocked) {
|
|
lockSession();
|
|
} else if (disconnectTimeout !== 0 && elapsedMinutes >= disconnectTimeout) {
|
|
autoDisconnect();
|
|
}
|
|
}
|
|
|
|
// Lock the session
|
|
function lockSession() {
|
|
if (isLocked) return;
|
|
|
|
isLocked = true;
|
|
document.getElementById('lockScreenModal').style.display = 'flex';
|
|
document.getElementById('unlockPassword').value = '';
|
|
document.getElementById('unlockError').style.display = 'none';
|
|
document.getElementById('unlockPassword').focus();
|
|
|
|
// Clear timers
|
|
if (lockTimer) clearTimeout(lockTimer);
|
|
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
|
|
console.log('Session locked due to inactivity');
|
|
}
|
|
|
|
// Unlock the session
|
|
async function unlockSession() {
|
|
const password = document.getElementById('unlockPassword').value;
|
|
if (!password) {
|
|
document.getElementById('unlockError').textContent = 'Please enter password';
|
|
document.getElementById('unlockError').style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
// Hash the password and verify against stored users
|
|
const hashedInput = await hashPassword(password);
|
|
|
|
// Try to find a matching user
|
|
let validUser = null;
|
|
for (const [username, storedHash] of Object.entries(storedUsers || {})) {
|
|
if (storedHash === hashedInput) {
|
|
validUser = username;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!validUser) {
|
|
// Also try current session
|
|
const response = await fetch(`${apiBaseUrl}/api/verify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token: authToken })
|
|
});
|
|
|
|
if (response.ok) {
|
|
validUser = currentUser;
|
|
}
|
|
}
|
|
|
|
if (validUser) {
|
|
isLocked = false;
|
|
lastActivityTime = Date.now();
|
|
document.getElementById('lockScreenModal').style.display = 'none';
|
|
resetInactivityTimers();
|
|
console.log('Session unlocked');
|
|
} else {
|
|
document.getElementById('unlockError').textContent = 'Incorrect password';
|
|
document.getElementById('unlockError').style.display = 'block';
|
|
document.getElementById('unlockPassword').value = '';
|
|
document.getElementById('unlockPassword').focus();
|
|
}
|
|
}
|
|
|
|
// Hash password for verification
|
|
async function hashPassword(password) {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(password);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
// Auto-disconnect due to inactivity
|
|
function autoDisconnect() {
|
|
console.log('Auto-disconnecting due to inactivity');
|
|
disconnect();
|
|
|
|
// Show notification
|
|
const notification = document.createElement('div');
|
|
notification.style.cssText = `
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: #f59e0b;
|
|
color: white;
|
|
padding: 15px 20px;
|
|
border-radius: 8px;
|
|
z-index: 10001;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
`;
|
|
notification.textContent = 'Disconnected due to inactivity';
|
|
document.body.appendChild(notification);
|
|
|
|
setTimeout(() => {
|
|
notification.remove();
|
|
}, 5000);
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', setupActivityTracking);
|