Files
webssh/src/static/webssh.js
T

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);