Switched from Dockmon to Beszel

This commit is contained in:
2025-10-31 17:13:00 +01:00
parent cc6454cef9
commit f4a4142799
75 changed files with 24313 additions and 122 deletions

2494
dockmon/src/css/main.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
:root {
/* Modern color scheme */
--primary: #0084F4;
--primary-dark: #0066CC;
--primary-light: #E8F4FD;
--success: #00C48C;
--warning: #FFA26B;
--danger: #FF647C;
--dark: #1A1D29;
--dark-secondary: #252837;
--dark-tertiary: #2D3142;
--text-primary: #FFFFFF;
--text-secondary: #A0A3BD;
--text-tertiary: #6E7191;
--border: #353849;
--surface: #1E2139;
--surface-light: #262A41;
--background: #161821;
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* Transitions */
--transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

BIN
dockmon/src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
dockmon/src/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

1015
dockmon/src/index.html Normal file

File diff suppressed because it is too large Load Diff

900
dockmon/src/js/alerts.js Normal file
View File

@@ -0,0 +1,900 @@
function createAlertForContainer(containerId) {
// Find the container
const container = containers.find(c => c.id === containerId);
if (container) {
// Check if there's an existing alert rule for this container on this specific host
const existingRule = alertRules.find(rule => {
// Check new container+host pairs format first
if (rule.containers && rule.containers.length > 0) {
return rule.containers.some(c =>
c.container_name === container.name && c.host_id === container.host_id
);
}
return false;
});
// Close current modal
closeModal('containerModal');
if (existingRule) {
// Edit existing rule
openAlertRuleModal(container, existingRule);
} else {
// Create new rule with container pre-selected
openAlertRuleModal(container);
}
}
}
async function editAlertRule(ruleId) {
try {
// Find the rule in the current alertRules array
const rule = alertRules.find(r => r.id === ruleId);
if (!rule) {
showToast('❌ Alert rule not found');
return;
}
editingAlertRule = rule;
openAlertRuleModal(null, rule);
} catch (error) {
logger.error('Error opening alert rule for editing:', error);
showToast('❌ Failed to open alert rule for editing');
}
}
function openGlobalSettings() {
document.getElementById('maxRetries').value = globalSettings.max_retries;
document.getElementById('retryDelay').value = globalSettings.retry_delay;
document.getElementById('pollingInterval').value = globalSettings.polling_interval;
document.getElementById('connectionTimeout').value = globalSettings.connection_timeout;
const defaultToggle = document.getElementById('defaultAutoRestart');
if (globalSettings.default_auto_restart) {
defaultToggle.classList.add('active');
} else {
defaultToggle.classList.remove('active');
}
// Dashboard display settings
const showHostStatsToggle = document.getElementById('showHostStats');
if (globalSettings.show_host_stats !== false) { // Default to true
showHostStatsToggle.classList.add('active');
} else {
showHostStatsToggle.classList.remove('active');
}
const showContainerStatsToggle = document.getElementById('showContainerStats');
if (globalSettings.show_container_stats !== false) { // Default to true
showContainerStatsToggle.classList.add('active');
} else {
showContainerStatsToggle.classList.remove('active');
}
document.getElementById('globalSettingsModal').classList.add('active');
}
function getSecurityStatusBadge(host) {
if (!host.url || host.url.includes('unix://')) {
return ''; // Local connections don't need security status
}
if (host.security_status === 'secure') {
return '<span class="security-status secure">Secure</span>';
} else if (host.security_status === 'insecure') {
return '<span class="security-status insecure">Insecure</span>';
}
return ''; // Unknown status - don't show anything
}
function closeModal(modalId) {
if (modalId === 'containerModal') {
// Save preferences before closing
saveModalPreferences();
// Clean up resize observer
if (window.modalResizeObserver) {
window.modalResizeObserver.disconnect();
window.modalResizeObserver = null;
}
// Use the cleanup function
if (window.cleanupLogStream) {
window.cleanupLogStream();
}
const streamBtn = document.getElementById('streamLogsBtn');
if (streamBtn) {
streamBtn.textContent = 'Start Live Stream';
}
// Remove keydown event listener if exists
if (window.logFilterKeyHandler) {
document.removeEventListener('keydown', window.logFilterKeyHandler);
}
// Remove drag event listeners
if (window.cleanupModalDragListeners) {
window.cleanupModalDragListeners();
}
// Clean up modal charts
if (typeof cleanupModalCharts === 'function') {
cleanupModalCharts();
}
// Notify backend to stop stats collection for this container
if (window.currentContainer && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'modal_closed',
container_id: window.currentContainer.id,
host_id: window.currentContainer.host_id
}));
}
// Clear current container
window.currentContainer = null;
}
document.getElementById(modalId).classList.remove('active');
}
// Account Settings Functions
let currentUserInfo = { username: 'admin' };
async function getCurrentUser() {
try {
const response = await fetch(`${API_BASE}/api/auth/status`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
currentUserInfo = data;
// Update UI with username
const topbarUsername = document.getElementById('topbarUsername');
const currentUsernameInput = document.getElementById('currentUsername');
if (topbarUsername) topbarUsername.textContent = data.username;
if (currentUsernameInput) currentUsernameInput.value = data.username;
}
} catch (error) {
logger.error('Error fetching user info:', error);
}
}
function openAccountSettings() {
getCurrentUser();
document.getElementById('accountModal').classList.add('active');
// Clear form
document.getElementById('newUsername').value = '';
document.getElementById('newPassword').value = '';
document.getElementById('confirmPassword').value = '';
document.getElementById('accountError').style.display = 'none';
document.getElementById('accountSuccess').style.display = 'none';
// Check if we have a temporary password from first login
const tempPassword = sessionStorage.getItem('temp_current_password');
if (tempPassword) {
document.getElementById('currentPassword').value = tempPassword;
// Clear it immediately after use for security
sessionStorage.removeItem('temp_current_password');
} else {
document.getElementById('currentPassword').value = '';
}
}
async function saveAccountSettings(event) {
event.preventDefault();
const currentPassword = document.getElementById('currentPassword').value;
const newUsername = document.getElementById('newUsername').value.trim();
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
const errorDiv = document.getElementById('accountError');
const successDiv = document.getElementById('accountSuccess');
errorDiv.style.display = 'none';
successDiv.style.display = 'none';
// Validate passwords match
if (newPassword && newPassword !== confirmPassword) {
errorDiv.textContent = 'New passwords do not match';
errorDiv.style.display = 'block';
return;
}
// Validate password length
if (newPassword && newPassword.length < 8) {
errorDiv.textContent = 'Password must be at least 8 characters long';
errorDiv.style.display = 'block';
return;
}
try {
// Change username if provided
if (newUsername && newUsername !== currentUserInfo.username) {
const usernameResponse = await fetch(`${API_BASE}/api/auth/change-username`, {
method: 'POST',
headers: getAuthHeaders(),
credentials: 'include',
body: JSON.stringify({
current_password: currentPassword,
new_username: newUsername
})
});
if (!usernameResponse.ok) {
const error = await usernameResponse.json();
throw new Error(error.detail || 'Failed to change username');
}
}
// Change password if provided
if (newPassword) {
const passwordResponse = await fetch(`${API_BASE}/api/auth/change-password`, {
method: 'POST',
headers: getAuthHeaders(),
credentials: 'include',
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
})
});
if (!passwordResponse.ok) {
const error = await passwordResponse.json();
throw new Error(error.detail || 'Failed to change password');
}
}
successDiv.textContent = 'Account updated successfully';
successDiv.style.display = 'block';
// Clear password change requirement
sessionStorage.removeItem('must_change_password');
// Update username displays
await getCurrentUser();
// Clear form
setTimeout(() => {
closeModal('accountModal');
}, 1500);
} catch (error) {
errorDiv.textContent = error.message;
errorDiv.style.display = 'block';
}
}
// logout() function removed - using the one from core.js instead
function checkPasswordChangeRequired() {
const mustChange = sessionStorage.getItem('must_change_password');
if (mustChange === 'true') {
// Show password change modal
setTimeout(() => {
showToast('⚠️ Please change your default password for security');
openAccountSettings();
// Add a notice at the top of the form for first login
const errorDiv = document.getElementById('accountError');
const successDiv = document.getElementById('accountSuccess');
errorDiv.style.display = 'block';
errorDiv.style.background = 'rgba(255, 193, 7, 0.1)';
errorDiv.style.borderColor = 'rgba(255, 193, 7, 0.3)';
errorDiv.style.color = '#ffc107';
errorDiv.textContent = '⚠️ First login detected. Please change your default password for security.';
}, 1000);
}
}
// Confirmation modal functions
let confirmationCallback = null;
function showConfirmation(title, message, buttonText, callback) {
document.getElementById('confirmationTitle').textContent = title;
document.getElementById('confirmationMessage').innerHTML = message;
document.getElementById('confirmationButton').textContent = buttonText;
confirmationCallback = callback;
document.getElementById('confirmationModal').classList.add('active');
}
function closeConfirmation() {
document.getElementById('confirmationModal').classList.remove('active');
confirmationCallback = null;
}
function confirmAction() {
if (confirmationCallback) {
confirmationCallback();
}
closeConfirmation();
}
function refreshContainerModalIfOpen() {
const containerModal = document.getElementById('containerModal');
if (containerModal && containerModal.classList.contains('active') && window.currentContainer) {
// Find the updated container data using the stored container ID
const updatedContainer = containers.find(c => c.host_id === window.currentContainer.host_id && c.short_id === window.currentContainer.short_id);
if (updatedContainer) {
// Remember which tab is currently active
let activeTab = 'info';
if (document.getElementById('logs-tab').style.display !== 'none') {
activeTab = 'logs';
} else if (document.getElementById('stats-tab').style.display !== 'none') {
activeTab = 'stats';
}
// Only refresh info tab if container state changed (not just stats update)
if (activeTab === 'info') {
// Check if this is just a stats update (state hasn't changed)
const stateChanged = updatedContainer.state !== window.currentContainer.state;
if (stateChanged) {
// Re-populate the modal with updated data (but preserve the tab)
showContainerDetails(updatedContainer.host_id, updatedContainer.short_id, activeTab);
} else {
// Just update the reference - sparklines will update automatically
window.currentContainer = updatedContainer;
// Refresh recent events every 30 seconds without full re-render
if (!window.lastEventsRefresh || (Date.now() - window.lastEventsRefresh) > 30000) {
if (typeof loadContainerRecentEvents === 'function') {
loadContainerRecentEvents(updatedContainer.name, updatedContainer.host_id);
window.lastEventsRefresh = Date.now();
}
}
}
} else {
// Just update the current container reference for stats/logs tabs
window.currentContainer = updatedContainer;
}
}
}
}
// Toggle functions
function toggleSwitch(element) {
element.classList.toggle('active');
}
async function toggleAutoRestart(hostId, containerId, event) {
event.stopPropagation();
// Find the container to get current state (must match both container ID and host)
const container = containers.find(c =>
(c.short_id === containerId || c.id === containerId) && c.host_id === hostId
);
if (!container) {
return;
}
const newState = !container.auto_restart;
// Find the host name
const host = hosts.find(h => h.id === hostId);
const hostName = host ? host.name : 'Unknown Host';
try {
const response = await fetch(`${API_BASE}/api/containers/${container.short_id}/auto-restart`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
host_id: hostId,
container_name: container.name,
enabled: newState
})
});
if (response.ok) {
// Update local state for all matching containers (same name + host)
containers.forEach(c => {
if (c.host_id === hostId && c.id === container.id) {
c.auto_restart = newState;
c.restart_attempts = 0;
}
});
renderHosts();
renderDashboardWidgets();
initIcons();
// Update modal content if it's open and showing this container
if (window.currentContainer && window.currentContainer.id === container.id && window.currentContainer.host_id === hostId) {
// Preserve current tab when updating for auto-restart
let activeTab = 'info';
if (document.getElementById('logs-tab').style.display !== 'none') {
activeTab = 'logs';
}
showContainerDetails(container.host_id, container.short_id, activeTab);
}
const status = newState ? 'enabled' : 'disabled';
showToast(`🔄 Auto-restart ${status} for ${container.name} on ${hostName}`);
if (newState && container.state === 'exited') {
// Trigger restart attempt
restartContainer(hostId, container.id);
}
} else {
showToast('❌ Failed to toggle auto-restart');
}
} catch (error) {
logger.error('Error toggling auto-restart:', error);
showToast('❌ Failed to toggle auto-restart');
}
}
// Actions
async function addHost(event) {
event.preventDefault();
const formData = new FormData(event.target);
const hostData = {
name: formData.get('hostname'),
url: formData.get('hosturl')
};
// Handle certificate data from either file upload or text input
const tlsCertificate = await getCertificateData(formData, 'tlscert');
const tlsKey = await getCertificateData(formData, 'tlskey');
const tlsCa = await getCertificateData(formData, 'tlsca');
if (tlsCertificate) hostData.tls_cert = tlsCertificate;
if (tlsKey) hostData.tls_key = tlsKey;
if (tlsCa) hostData.tls_ca = tlsCa;
const isEditing = window.editingHost !== null;
const url = isEditing ? `${API_BASE}/api/hosts/${window.editingHost.id}` : `${API_BASE}/api/hosts`;
const method = isEditing ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(hostData)
});
if (response.ok) {
const updatedHost = await response.json();
if (isEditing) {
showToast('✅ Host updated successfully!');
} else {
showToast('✅ Host added successfully!');
}
// Refresh hosts and containers from server to ensure we have complete data
await fetchHosts();
await fetchContainers();
// Explicitly refresh the host list on the Host Management page
renderHosts();
renderHostsPage(); // Make sure the Host Management page is updated too
updateStats();
updateNavBadges();
closeModal('hostModal');
event.target.reset();
window.editingHost = null;
} else {
const action = isEditing ? 'update' : 'add';
try {
const errorData = await response.json();
// Handle Pydantic validation errors (array format)
if (errorData.detail && Array.isArray(errorData.detail)) {
const messages = errorData.detail.map(err => {
// Root validators have __root__ as location
if (err.loc && err.loc.includes('__root__')) {
return err.msg.replace('Value error, ', '');
}
// Field-specific errors
const field = err.loc ? err.loc[err.loc.length - 1] : 'field';
const fieldName = field === 'tls_ca' ? 'CA Certificate' :
field === 'tls_cert' ? 'Client Certificate' :
field === 'tls_key' ? 'Client Private Key' : field;
return `${fieldName}: ${err.msg.replace('Value error, ', '')}`;
});
showToast(`❌ Failed to ${action} host:\n${messages.join('\n')}`);
}
// Handle simple error messages (string format)
else if (errorData.detail && typeof errorData.detail === 'string') {
showToast(`❌ Failed to ${action} host: ${errorData.detail}`);
}
else {
showToast(`❌ Failed to ${action} host`);
}
} catch (parseError) {
// If JSON parsing fails, show raw text
const errorText = await response.text();
showToast(`❌ Failed to ${action} host: ${errorText}`);
}
}
} catch (error) {
const action = isEditing ? 'update' : 'add';
logger.error(`Error ${action}ing host:`, error);
showToast(`❌ Failed to ${action} host`);
}
}
async function createAlertRule(event) {
event.preventDefault();
const states = [];
const channels = [];
let containerPattern = '';
let hostId = null;
let containerHostPairs = [];
// Determine container selection
// Get selected containers with their host IDs
const allCheckboxes = document.querySelectorAll('#containerSelectionCheckboxes input[type="checkbox"]');
const checkedCheckboxes = document.querySelectorAll('#containerSelectionCheckboxes input[type="checkbox"]:checked');
// If all checkboxes are checked, treat it as "monitor all containers"
if (allCheckboxes.length > 0 && allCheckboxes.length === checkedCheckboxes.length) {
containerPattern = '.*';
hostId = null;
// Don't use container+host pairs for "all containers"
containerHostPairs = []; // Clear any previously selected containers
} else {
// Get selected containers with their host IDs
checkedCheckboxes.forEach(cb => {
if (cb.dataset.hostId && cb.value) {
containerHostPairs.push({
host_id: cb.dataset.hostId,
container_name: cb.value
});
}
});
if (containerHostPairs.length === 0) {
showToast('❌ Please select at least one container');
return;
}
// For the new system, we don't need to build patterns
// The backend will handle the container+host pairs directly
containerPattern = null; // Will be set to ".*" by backend as default
}
// Get selected events, states, and channels
const events = [];
event.target.querySelectorAll('input[type="checkbox"]:checked').forEach(cb => {
if (cb.name === 'channels') {
channels.push(parseInt(cb.value));
} else if (cb.dataset.event) {
events.push(cb.dataset.event);
} else if (cb.dataset.state) {
states.push(cb.dataset.state);
}
});
const ruleName = document.getElementById('alertRuleName').value;
const cooldownMinutes = parseInt(document.getElementById('cooldownMinutes').value) || 15;
// Validate that at least one trigger is selected
if (events.length === 0 && states.length === 0) {
showToast('❌ Please select at least one Docker event or state to monitor');
return;
}
if (channels.length === 0) {
showToast('❌ Please select at least one notification channel');
return;
}
const ruleData = {
name: ruleName,
containers: containerHostPairs.length > 0 ? containerHostPairs : null,
trigger_events: events.length > 0 ? events : [],
trigger_states: states.length > 0 ? states : [],
notification_channels: channels,
cooldown_minutes: cooldownMinutes,
enabled: true
};
const isEditing = editingAlertRule !== null;
const url = isEditing ? `${API_BASE}/api/alerts/${editingAlertRule.id}` : `${API_BASE}/api/alerts`;
const method = isEditing ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(ruleData)
});
if (response.ok) {
const rule = await response.json();
if (isEditing) {
showToast('✅ Alert rule updated successfully!');
editingAlertRule = null;
} else {
showToast('✅ Alert rule created successfully!');
}
// Fetch fresh data from server to ensure UI shows correct information
await fetchAlertRules();
renderAlertRules();
updateStats();
updateNavBadges();
closeModal('alertRuleModal');
} else {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
logger.error(`Alert ${isEditing ? 'update' : 'creation'} failed:`, errorData);
const errorMessage = typeof errorData.detail === 'string'
? errorData.detail
: JSON.stringify(errorData.detail) || response.statusText;
showToast(`❌ Failed to ${isEditing ? 'update' : 'create'} alert: ${errorMessage}`);
}
} catch (error) {
logger.error(`Error ${editingAlertRule ? 'updating' : 'creating'} alert rule:`, error);
showToast(`❌ Failed to ${editingAlertRule ? 'update' : 'create'} alert rule`);
}
}
async function checkDependentAlerts(channelId) {
try {
const response = await fetch(`${API_BASE}/api/notifications/channels/${channelId}/dependent-alerts`, {
credentials: 'include'
});
const data = await response.json();
return data.alerts || [];
} catch (error) {
logger.error('Error checking dependent alerts:', error);
return [];
}
}
// Blackout Windows Management
function renderBlackoutWindows() {
const container = document.getElementById('blackoutWindowsList');
if (!container) return;
if (!globalSettings.blackout_windows || globalSettings.blackout_windows.length === 0) {
container.innerHTML = '<div style="padding: var(--spacing-md); color: var(--text-secondary); text-align: center;">No blackout windows configured</div>';
return;
}
container.innerHTML = globalSettings.blackout_windows.map((window, index) => `
<div style="padding: var(--spacing-md); margin-bottom: var(--spacing-md); background: var(--surface); border: 1px solid var(--surface-light); border-radius: var(--radius-md);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-sm);">
<input type="text" placeholder="Window name (e.g., Nightly Maintenance)"
value="${escapeHtml(window.name || '')}"
onchange="updateBlackoutWindow(${index}, 'name', this.value)"
style="background: transparent; border: none; color: var(--text-primary); font-size: 14px; flex: 1;">
<button type="button" class="btn-icon" onclick="removeBlackoutWindow(${index})">
<i data-lucide="trash-2"></i>
</button>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: var(--spacing-sm); align-items: center;">
<div>
<label style="font-size: 12px; color: var(--text-secondary);">Start Time</label>
<input type="time" class="form-input" value="${window.start || '02:00'}"
onchange="updateBlackoutWindow(${index}, 'start', this.value)">
</div>
<div>
<label style="font-size: 12px; color: var(--text-secondary);">End Time</label>
<input type="time" class="form-input" value="${window.end || '04:00'}"
onchange="updateBlackoutWindow(${index}, 'end', this.value)">
</div>
<div style="padding-top: 18px;">
<label class="toggle-label">
<input type="checkbox" ${window.enabled !== false ? 'checked' : ''}
onchange="updateBlackoutWindow(${index}, 'enabled', this.checked)">
<span>Enabled</span>
</label>
</div>
</div>
<div style="margin-top: var(--spacing-sm);">
<label style="font-size: 12px; color: var(--text-secondary); display: block; margin-bottom: var(--spacing-xs);">Active Days</label>
<div style="display: flex; gap: var(--spacing-xs); flex-wrap: wrap;">
${['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map((day, dayIndex) => `
<label class="day-checkbox" style="display: flex; align-items: center; gap: 4px; padding: 4px 8px; background: ${window.days && window.days.includes(dayIndex) ? 'var(--primary)' : 'var(--surface-light)'}; border-radius: var(--radius-sm); cursor: pointer;">
<input type="checkbox" ${window.days && window.days.includes(dayIndex) ? 'checked' : ''}
onchange="toggleBlackoutDay(${index}, ${dayIndex}, this.checked)"
style="display: none;">
<span style="font-size: 12px; color: ${window.days && window.days.includes(dayIndex) ? 'white' : 'var(--text-secondary)'};">${day}</span>
</label>
`).join('')}
</div>
</div>
</div>
`).join('');
initIcons();
}
function addBlackoutWindow() {
if (!globalSettings.blackout_windows) {
globalSettings.blackout_windows = [];
}
// Find the next available window number
const existingNumbers = globalSettings.blackout_windows
.map(w => w.name.match(/Window (\d+)/))
.filter(m => m)
.map(m => parseInt(m[1]));
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1;
globalSettings.blackout_windows.push({
name: `Window ${nextNumber}`,
start: '02:00',
end: '04:00',
days: [0, 1, 2, 3, 4, 5, 6], // All days by default
enabled: true
});
renderBlackoutWindows();
}
function removeBlackoutWindow(index) {
globalSettings.blackout_windows.splice(index, 1);
renderBlackoutWindows();
}
function updateBlackoutWindow(index, field, value) {
if (globalSettings.blackout_windows && globalSettings.blackout_windows[index]) {
globalSettings.blackout_windows[index][field] = value;
}
}
function toggleBlackoutDay(windowIndex, dayIndex, checked) {
if (!globalSettings.blackout_windows[windowIndex].days) {
globalSettings.blackout_windows[windowIndex].days = [];
}
const days = globalSettings.blackout_windows[windowIndex].days;
if (checked && !days.includes(dayIndex)) {
days.push(dayIndex);
} else if (!checked) {
const idx = days.indexOf(dayIndex);
if (idx > -1) days.splice(idx, 1);
}
renderBlackoutWindows();
}
async function saveBlackoutWindows() {
try {
// Add timezone offset (in minutes, negative for timezones ahead of UTC)
globalSettings.timezone_offset = -new Date().getTimezoneOffset();
const response = await fetch(`${API_BASE}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(globalSettings)
});
if (response.ok) {
showToast('✅ Blackout windows saved successfully!');
await updateBlackoutStatus();
}
} catch (error) {
logger.error('Error saving blackout windows:', error);
showToast('❌ Failed to save blackout windows');
}
}
async function updateBlackoutStatus() {
try {
const response = await fetch(`${API_BASE}/api/blackout/status`, {
credentials: 'include'
});
const status = await response.json();
const statusDiv = document.getElementById('blackoutStatus');
if (statusDiv) {
if (status.is_blackout) {
statusDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
<i data-lucide="moon" style="width:20px;height:20px;color:var(--warning);"></i>
<div>
<div style="color: var(--warning); font-weight: bold;">Currently in Blackout Window</div>
<div style="color: var(--text-secondary); font-size: 12px;">${status.current_window}</div>
</div>
</div>
`;
initIcons();
} else {
statusDiv.innerHTML = `
<div style="display: flex; align-items: center; gap: var(--spacing-sm);">
<i data-lucide="sun" style="width:20px;height:20px;color:var(--success);"></i>
<div>
<div style="color: var(--text-primary);">No active blackout window</div>
<div style="color: var(--text-secondary); font-size: 12px;">Alerts are being sent normally</div>
</div>
</div>
`;
initIcons();
}
// Update dashboard indicator
const dashboardIndicator = document.getElementById('quietHoursIndicator');
if (dashboardIndicator) {
if (status.is_blackout) {
dashboardIndicator.style.display = 'flex';
dashboardIndicator.innerHTML = `
<i data-lucide="moon"></i>
<span>In Blackout Window</span>
`;
initIcons();
} else {
dashboardIndicator.style.display = 'none';
}
}
}
} catch (error) {
logger.error('Error fetching blackout status:', error);
}
}
async function saveGlobalSettings() {
globalSettings.max_retries = parseInt(document.getElementById('maxRetries').value);
globalSettings.retry_delay = parseInt(document.getElementById('retryDelay').value);
globalSettings.polling_interval = parseInt(document.getElementById('pollingInterval').value);
globalSettings.connection_timeout = parseInt(document.getElementById('connectionTimeout').value);
globalSettings.default_auto_restart = document.getElementById('defaultAutoRestart').classList.contains('active');
globalSettings.show_host_stats = document.getElementById('showHostStats').classList.contains('active');
globalSettings.show_container_stats = document.getElementById('showContainerStats').classList.contains('active');
// Include blackout windows in the save
try {
const response = await fetch(`${API_BASE}/api/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(globalSettings)
});
if (response.ok) {
showToast('✅ Settings saved successfully!');
closeModal('globalSettingsModal');
// Re-render dashboard to apply show_host_stats and show_container_stats changes
if (typeof renderHosts === 'function') {
renderHosts();
}
}
} catch (error) {
logger.error('Error saving settings:', error);
showToast('❌ Failed to save settings');
}
}
async function deleteAlertRule(ruleId) {
const rule = alertRules.find(r => r.id === ruleId);
const ruleName = rule ? rule.name : 'Unknown Rule';
const message = `Are you sure you want to delete the alert rule <strong>"${escapeHtml(ruleName)}"</strong>?<br><br>
This will permanently remove the alert rule and you will no longer receive notifications for this container.<br><br>
<strong>This action cannot be undone.</strong>`;
showConfirmation('Delete Alert Rule', message, 'Delete Rule', async () => {
try {
const response = await fetch(`${API_BASE}/api/alerts/${ruleId}`, {
method: 'DELETE'
});
if (response.ok) {
showToast('Alert rule deleted');
// Fetch fresh data from server to ensure UI is up to date
await fetchAlertRules();
renderAlertRules();
updateStats();
updateNavBadges();
}
} catch (error) {
logger.error('Error deleting alert rule:', error);
showToast('❌ Failed to delete alert rule');
}
});
}

2211
dockmon/src/js/app.js Normal file

File diff suppressed because it is too large Load Diff

640
dockmon/src/js/core.js Normal file
View File

@@ -0,0 +1,640 @@
logger.debug('DockMon JavaScript loaded');
// Security: HTML escaping function to prevent XSS
function escapeHtml(unsafe) {
if (unsafe === null || unsafe === undefined) return '';
return String(unsafe)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Make escapeHtml globally available
window.escapeHtml = escapeHtml;
// Global state
let currentPage = 'dashboard';
let globalSettings = {
maxRetries: 3,
retryDelay: 30,
defaultAutoRestart: false,
pollingInterval: 2,
connectionTimeout: 10,
blackout_windows: []
};
let hosts = [];
let containers = [];
let alertRules = [];
// Make hosts and containers globally accessible for other modules (like logs.js)
window.hosts = hosts;
window.containers = containers;
let ws = null;
// Auto-restart notification batching
let restartNotificationBatch = [];
let restartNotificationTimer = null;
let editingAlertRule = null; // Track which rule is being edited
let reconnectAttempts = 0;
let isConnecting = false; // Prevent multiple simultaneous connections
const MAX_RECONNECT_ATTEMPTS = 10;
// API Base URL - backend always runs on port 8080
const API_BASE = ''; // Use same origin - nginx will proxy /api/* to backend
const WS_URL = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`;
// Note: escapeHtml is defined at the top of this file and made globally available
// Lucide icon helper function
function icon(name, size = 16, className = '') {
return `<i data-lucide="${name}" class="lucide-icon ${className}" style="width:${size}px;height:${size}px;"></i>`;
}
// Initialize Lucide icons after DOM updates
function initIcons() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
// Get authentication headers for API requests
function getAuthHeaders() {
const headers = {
'Content-Type': 'application/json'
};
// Using session cookies for authentication
return headers;
}
// Initialize WebSocket connection
function connectWebSocket() {
// Prevent multiple simultaneous connection attempts
if (isConnecting) {
logger.debug('Connection attempt already in progress, skipping...');
return;
}
// Set flag immediately to prevent race condition
isConnecting = true;
// Close existing connection if any
if (ws && ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
logger.debug('Connecting to WebSocket:', WS_URL);
ws = new WebSocket(WS_URL);
ws.onopen = function() {
logger.debug('WebSocket connected');
showToast('✅ Connected to backend');
reconnectAttempts = 0;
isConnecting = false;
};
ws.onmessage = function(event) {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (error) {
logger.error('Error handling WebSocket message:', error);
}
};
ws.onerror = function(error) {
logger.error('WebSocket error:', error);
showToast('⚠️ Connection error');
isConnecting = false; // Reset flag on error
};
ws.onclose = function() {
logger.debug('WebSocket disconnected');
showToast('🔌 Disconnected - attempting to reconnect...');
isConnecting = false; // Reset flag on close
attemptReconnect();
};
}
// Handle batched restart notifications
function handleRestartNotificationBatch() {
if (restartNotificationBatch.length === 0) return;
if (restartNotificationBatch.length === 1) {
showToast(`✅ Successfully restarted ${restartNotificationBatch[0]}`);
} else {
const containerNames = restartNotificationBatch.slice(0, 3).join(', ');
const remaining = Math.max(0, restartNotificationBatch.length - 3);
const message = remaining > 0
? `✅ Successfully restarted ${containerNames} and ${remaining} more`
: `✅ Successfully restarted ${containerNames}`;
showToast(message);
}
// Clear the batch
restartNotificationBatch = [];
restartNotificationTimer = null;
}
// Handle incoming WebSocket messages
function handleWebSocketMessage(message) {
switch(message.type) {
case 'initial_state':
hosts = message.data.hosts || [];
window.hosts = hosts; // Keep window.hosts in sync
containers = message.data.containers || [];
window.containers = containers; // Keep window.containers in sync
globalSettings = message.data.settings || globalSettings;
alertRules = message.data.alerts || [];
renderAll();
// Initialize dashboard if we're on that page and grid doesn't exist yet
if (currentPage === 'dashboard' && grid === null) {
// Small delay to ensure renderAll() completes
setTimeout(() => {
if (grid === null) { // Double check grid wasn't already initialized
initDashboard();
}
}, 100);
}
break;
case 'containers_update':
containers = message.data.containers || [];
window.containers = containers; // Keep window.containers in sync
hosts = message.data.hosts || [];
window.hosts = hosts; // Keep window.hosts in sync
renderAll();
// Update host metrics charts
if (message.data.host_metrics && typeof updateHostMetrics === 'function') {
for (const [hostId, metrics] of Object.entries(message.data.host_metrics)) {
updateHostMetrics(hostId, metrics);
}
}
// Update container metrics sparklines
if (typeof updateContainerSparklines === 'function') {
for (const container of containers) {
updateContainerSparklines(container.host_id, container.short_id, container);
}
}
// Refresh container modal if open
refreshContainerModalIfOpen();
// Refresh logs dropdown if on logs page
if (currentPage === 'logs' && typeof populateContainerList === 'function') {
populateContainerList();
}
break;
case 'host_added':
fetchHosts();
fetchContainers(); // Fetch containers to show new host's containers
showToast('✅ Host added successfully');
break;
case 'host_removed':
fetchHosts();
fetchContainers(); // Refresh to remove containers from deleted host
showToast('🗑️ Host removed successfully');
break;
case 'auto_restart_success':
// Add to batch instead of showing immediate toast
restartNotificationBatch.push(message.data.container_name);
// Clear any existing timer and set a new one
if (restartNotificationTimer) {
clearTimeout(restartNotificationTimer);
}
// Show batched notification after 2 seconds of no new restarts
restartNotificationTimer = setTimeout(handleRestartNotificationBatch, 2000);
break;
case 'auto_restart_failed':
showToast(`❌ Failed to restart ${message.data.container_name} after ${message.data.attempts} attempts`);
break;
case 'container_restarted':
showToast(`🔄 Container restarted`);
break;
case 'docker_event':
// Handle Docker events (container start/stop/restart, etc.)
// These are real-time events from Docker daemon - no action needed
break;
case 'blackout_status_changed':
// Update blackout status display if modal is open
updateBlackoutStatus();
break;
default:
logger.debug('Unknown message type:', message.type);
}
// Call any registered custom message handlers
if (window.wsMessageHandlers && Array.isArray(window.wsMessageHandlers)) {
window.wsMessageHandlers.forEach(handler => {
try {
handler(message);
} catch (error) {
logger.error('Error in custom WebSocket handler:', error);
}
});
}
}
// Core handler stays internal - extensions should use window.wsMessageHandlers array
// Attempt to reconnect to WebSocket
function attemptReconnect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
showToast('❌ Could not reconnect to backend');
// Add persistent warning banner
let banner = document.getElementById('connection-lost-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'connection-lost-banner';
banner.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; background: #dc2626; color: white; padding: 12px; text-align: center; z-index: 10000; font-weight: 600; box-shadow: 0 2px 8px rgba(0,0,0,0.3);';
banner.innerHTML = '⚠️ Connection to backend lost. Data may be outdated. <button onclick="location.reload()" style="margin-left: 16px; padding: 6px 16px; background: white; color: #dc2626; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Reload Page</button>';
document.body.prepend(banner);
}
return;
}
// Use exponential backoff: 2s, 4s, 8s, 16s, then 30s
const delay = Math.min(2000 * Math.pow(2, reconnectAttempts), 30000);
logger.debug(`Will attempt reconnect in ${delay}ms (attempt ${reconnectAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
setTimeout(() => {
reconnectAttempts++;
logger.debug(`Reconnect attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
connectWebSocket();
}, delay);
}
// API Functions
async function fetchHosts() {
try {
const response = await fetch(`${API_BASE}/api/hosts`, {
headers: getAuthHeaders(),
credentials: 'include'
});
if (response.ok) {
hosts = await response.json();
window.hosts = hosts; // Keep window.hosts in sync
logger.debug('Fetched hosts:', hosts.length);
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
logger.error('Error fetching hosts:', error);
// Don't show toast during initial load, WebSocket will handle it
if (hosts.length === 0) {
logger.debug('Will wait for WebSocket data...');
} else {
showToast('❌ Failed to fetch hosts');
}
}
}
async function fetchContainers() {
try {
const response = await fetch(`${API_BASE}/api/containers`, {
headers: getAuthHeaders(),
credentials: 'include'
});
if (response.ok) {
containers = await response.json();
window.containers = containers; // Keep window.containers in sync
logger.debug('Fetched containers:', containers.length);
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
logger.error('Error fetching containers:', error);
// Don't show toast during initial load, WebSocket will handle it
if (containers.length === 0) {
logger.debug('Will wait for WebSocket data...');
} else {
showToast('❌ Failed to fetch containers');
}
}
}
async function fetchSettings() {
try {
const response = await fetch(`${API_BASE}/api/settings`, {
credentials: 'include'
});
globalSettings = await response.json();
// Always update timezone offset from browser
globalSettings.timezone_offset = -new Date().getTimezoneOffset();
} catch (error) {
logger.error('Error fetching settings:', error);
}
}
async function fetchAlertRules() {
try {
const response = await fetch(`${API_BASE}/api/alerts`, {
credentials: 'include'
});
if (response.ok) {
alertRules = await response.json() || [];
renderAlertRules();
updateNavBadges();
} else {
logger.warn('Failed to fetch alert rules:', response.status);
alertRules = [];
}
} catch (error) {
logger.error('Error fetching alert rules:', error);
alertRules = [];
}
}
// Check authentication status
async function checkAuthentication() {
logger.debug('Checking authentication status...');
try {
const response = await fetch(`${API_BASE}/api/auth/status`, {
credentials: 'include'
});
logger.debug('Auth response status:', response.status);
if (response.ok) {
const data = await response.json();
logger.debug('Auth data:', data);
if (!data.authenticated) {
logger.debug('Not authenticated, redirecting to login');
// Redirect to login page
window.location.href = '/login.html';
return false;
}
// Store user info
if (data.username) {
currentUserInfo.username = data.username;
}
// Check if password change is required (from backend)
if (data.must_change_password || data.is_first_login) {
sessionStorage.setItem('must_change_password', 'true');
}
logger.debug('Authentication successful');
return true;
} else {
logger.debug('Auth check returned non-OK status:', response.status);
}
} catch (error) {
logger.error('Auth check error:', error);
}
logger.debug('Auth check failed, redirecting to login');
// If auth check fails, redirect to login
window.location.href = '/login.html';
return false;
}
// Logout function
async function logout() {
try {
const response = await fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
window.location.href = '/login.html';
} else {
logger.error('Logout failed');
}
} catch (error) {
logger.error('Logout error:', error);
// Force redirect even if logout request fails
window.location.href = '/login.html';
}
}
// Save modal preferences to localStorage
async function saveModalPreferences() {
const modal = document.querySelector('#containerModal .modal-content');
if (modal) {
// Check if mobile view
const isMobile = window.innerWidth < 768;
if (isMobile) {
return; // Don't save preferences on mobile
}
const preferences = {
width: modal.style.width,
height: modal.style.height,
transform: modal.style.transform,
logsHeight: document.getElementById('container-logs')?.style.height
};
try {
const response = await fetch(`${API_BASE}/api/user/modal-preferences`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferences: JSON.stringify(preferences) })
});
if (!response.ok) {
console.error('Failed to save modal preferences');
}
} catch (e) {
console.error('Failed to save modal preferences:', e);
}
}
}
// Load modal preferences from database
async function loadModalPreferences() {
try {
const response = await fetch(`${API_BASE}/api/user/modal-preferences`);
if (response.ok) {
const data = await response.json();
if (data.preferences) {
const prefs = JSON.parse(data.preferences);
// Check if mobile view - don't apply saved preferences on mobile
const isMobile = window.innerWidth < 768;
if (isMobile) {
return null;
}
const modal = document.querySelector('#containerModal .modal-content');
if (modal) {
if (prefs.width) modal.style.width = prefs.width;
if (prefs.height) modal.style.height = prefs.height;
// Reset transform on load (center the modal)
modal.style.transform = 'translate(0, 0)';
}
// Restore logs height will be done when logs tab is shown
return prefs;
}
}
} catch (e) {
console.error('Failed to load modal preferences:', e);
}
return null;
}
// Make modal draggable
function makeModalDraggable(modalId) {
const modal = document.getElementById(modalId);
const modalContent = modal.querySelector('.modal-content');
const header = modalContent.querySelector('.modal-header');
let isDragging = false;
let currentX;
let currentY;
let initialX;
let initialY;
let xOffset = 0;
let yOffset = 0;
function dragStart(e) {
if (e.target.classList.contains('modal-close')) return;
initialX = e.clientX - xOffset;
initialY = e.clientY - yOffset;
if (e.target === header || header.contains(e.target)) {
isDragging = true;
}
}
function drag(e) {
if (isDragging) {
e.preventDefault();
currentX = e.clientX - initialX;
currentY = e.clientY - initialY;
xOffset = currentX;
yOffset = currentY;
modalContent.style.transform = `translate(${currentX}px, ${currentY}px)`;
}
}
function dragEnd(e) {
initialX = currentX;
initialY = currentY;
isDragging = false;
// Save position after dragging
saveModalPreferences();
}
// Add event listeners
header.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
// Store cleanup function for later removal
window.cleanupModalDragListeners = function() {
header.removeEventListener('mousedown', dragStart);
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', dragEnd);
};
}
// ========================================
// Chart.js Helper Functions
// ========================================
/**
* Create a sparkline chart (small, minimal chart for trends)
* @param {HTMLCanvasElement} canvas - Canvas element to render chart
* @param {string} color - Line color
* @param {number} maxDataPoints - Maximum number of data points to show
* @returns {Chart} Chart.js instance
*/
function createSparkline(canvas, color, maxDataPoints = 60) {
const ctx = canvas.getContext('2d');
return new Chart(ctx, {
type: 'line',
data: {
labels: new Array(maxDataPoints).fill(''),
datasets: [{
data: new Array(maxDataPoints).fill(0),
borderColor: color,
backgroundColor: 'transparent',
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 0,
tension: 0.4,
fill: false
}]
},
options: {
responsive: false,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { enabled: false }
},
scales: {
x: { display: false },
y: {
display: false,
min: 0,
max: 100
}
},
animation: {
duration: 0
},
interaction: {
mode: 'index',
intersect: false
}
}
});
}
/**
* Update sparkline chart with new data point
* @param {Chart} chart - Chart.js instance
* @param {number} newValue - New data value
*/
function updateSparkline(chart, newValue) {
const data = chart.data.datasets[0].data;
data.shift();
data.push(newValue);
chart.update('none'); // Update without animation
}
/**
* Format bytes to human-readable format
* @param {number} bytes - Number of bytes
* @returns {string} Formatted string (e.g., "1.2 MB/s")
*/
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
if (typeof bytes !== 'number' || !isFinite(bytes) || bytes < 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[Math.min(i, sizes.length - 1)];
}
// Initialize

837
dockmon/src/js/dashboard.js Normal file
View File

@@ -0,0 +1,837 @@
// GridStack Dashboard
let grid = null;
let dashboardLocked = false;
async function initDashboard() {
logger.debug('initDashboard called');
// Initialize search and sort filters
initializeContainerFilters();
// Show search/sort controls on dashboard
const searchContainer = document.getElementById('dashboardSearchContainer');
const sortSelect = document.getElementById('containerSort');
if (searchContainer) searchContainer.style.display = '';
if (sortSelect) sortSelect.style.display = '';
// Check if dashboard grid container exists
const dashboardGridElement = document.getElementById('dashboard-grid');
if (!dashboardGridElement) {
logger.error('Dashboard grid element not found!');
return;
}
// Destroy existing GridStack instance if it exists
if (grid) {
grid.destroy(false); // false = don't remove DOM elements
grid = null;
logger.debug('Destroyed existing GridStack instance');
}
// Initialize GridStack with better flexibility
try {
grid = GridStack.init({
column: 48, // 48 columns for maximum flexibility
cellHeight: 20, // Smaller cells for finer vertical control
margin: 4, // Keep margins at 4px
animate: true,
float: true, // Allow floating for desktop editing
draggable: {
handle: '.widget-header'
},
resizable: {
handles: 'e, se, s, sw, w'
}
}, '#dashboard-grid');
logger.debug('GridStack initialized successfully');
// Load saved layout from API or use default
try {
const response = await fetch('/api/user/dashboard-layout', {
method: 'GET',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.layout) {
try {
const parsedLayout = JSON.parse(data.layout);
logger.debug('Loading saved dashboard layout from API - widgets:', parsedLayout.map(w => w.id));
loadDashboardLayout(parsedLayout);
} catch (parseError) {
logger.error('Failed to parse dashboard layout JSON:', parseError);
showToast('⚠️ Dashboard layout corrupted - using default', 'error');
createDefaultDashboard();
}
} else {
logger.debug('No saved layout in API - creating default dashboard layout');
createDefaultDashboard();
}
} else {
logger.error('Failed to load dashboard layout from API:', response.status);
showToast('⚠️ Failed to load dashboard layout - using default', 'error');
createDefaultDashboard();
}
} catch (error) {
logger.error('Error loading dashboard layout:', error);
showToast('⚠️ Failed to load dashboard layout - using default', 'error');
createDefaultDashboard();
}
// Auto-save layout on any change
grid.on('change', (event, items) => {
saveDashboardLayout();
});
logger.debug('Dashboard initialization completed');
// Now that grid exists, populate the widgets with data
logger.debug('Rendering dashboard widgets after grid initialization...');
renderDashboardWidgets();
} catch (error) {
logger.error('Failed to initialize dashboard:', error);
}
}
function createDefaultDashboard() {
// Stats Widget - taller to ensure content fits
const statsWidget = createWidget('stats', 'Statistics', '<span data-lucide="bar-chart-3"></span>', {
x: 0, y: 0, w: 48, h: 9,
minW: 48, minH: 9, maxH: 9, maxW: 48,
noResize: true,
noMove: true
});
// Create individual widgets for each host
createHostWidgets();
// Use setTimeout to ensure widgets are fully created before rendering
setTimeout(() => renderDashboardWidgets(), 50);
}
function createHostWidgets() {
// Layout is loaded from API on startup, no need to check localStorage
const layoutMap = {};
// Get current host widget IDs
const currentHostWidgetIds = hosts.map(host => `host-${host.id}`);
// Remove host widgets that no longer exist OR duplicates
const existingHostWidgets = grid.getGridItems().filter(item => {
const widgetId = item.getAttribute('data-widget-id');
return widgetId && widgetId.startsWith('host-');
});
logger.debug(`Checking for widgets to remove. Current: ${existingHostWidgets.length}, Expected: ${currentHostWidgetIds.length}`);
// Track which widget IDs we've seen to detect duplicates
const seenWidgetIds = new Set();
existingHostWidgets.forEach(widget => {
const widgetId = widget.getAttribute('data-widget-id');
// Remove if widget ID is not in current host list
if (!currentHostWidgetIds.includes(widgetId)) {
logger.debug(`Removing widget ${widgetId} - not in current host list`);
// Extract host ID from widget ID (format: "host-{hostId}")
const hostId = widgetId.replace('host-', '');
// Clean up metrics before removing widget
if (typeof removeHostMetrics === 'function') {
removeHostMetrics(hostId);
}
grid.removeWidget(widget);
}
// Remove if this is a duplicate (we've seen this ID before)
else if (seenWidgetIds.has(widgetId)) {
logger.debug(`Removing duplicate widget ${widgetId}`);
// Extract host ID from widget ID (format: "host-{hostId}")
const hostId = widgetId.replace('host-', '');
// Clean up metrics before removing widget
if (typeof removeHostMetrics === 'function') {
removeHostMetrics(hostId);
}
grid.removeWidget(widget);
} else {
seenWidgetIds.add(widgetId);
}
});
// Create a widget for each host with dynamic sizing and smart positioning
let currentY = 10; // Start below stats widget (stats h=9, +1 for spacing)
let leftColumnY = 10;
let rightColumnY = 10;
// FIRST: Scan ALL existing widgets to find the actual bottom of each column
// This ensures new widgets are placed at the bottom regardless of host creation order
const allExistingWidgets = grid.getGridItems();
allExistingWidgets.forEach(widget => {
const widgetId = widget.getAttribute('data-widget-id');
// Only process host widgets, not stats widget
if (!widgetId || !widgetId.startsWith('host-')) {
return;
}
// Use GridStack node data instead of attributes
const gridData = widget.gridstackNode;
if (!gridData) {
return;
}
const existingX = gridData.x;
const existingY = gridData.y;
const existingH = gridData.h;
if (existingX === 0) {
// Left column
leftColumnY = Math.max(leftColumnY, existingY + existingH);
} else {
// Right column
rightColumnY = Math.max(rightColumnY, existingY + existingH);
}
});
hosts.forEach((host, index) => {
const widgetId = `host-${host.id}`;
// Check if widget already exists
const existingWidget = document.querySelector(`[data-widget-id="${widgetId}"]`);
if (existingWidget) {
return; // Skip silently - widget already exists
}
logger.debug(`Creating new widget ${widgetId}`);
const hostContainers = containers.filter(c => c.host_id === host.id);
// Calculate height based on container count (adjusted for cellHeight=20)
const containerRows = Math.max(1, hostContainers.length);
const headerHeight = 4; // Widget header (doubled for smaller cells)
const containerHeight = 3.6; // Each container row height (doubled)
const dynamicHeight = headerHeight + containerRows * containerHeight + 1.2; // Add padding
const widgetHeight = Math.max(12, Math.ceil(dynamicHeight)); // Minimum height of 12
// Check if there's a saved position for this widget
let x, y, w, h;
if (layoutMap[widgetId]) {
// Use saved position and size
x = layoutMap[widgetId].x;
y = layoutMap[widgetId].y;
w = layoutMap[widgetId].w;
h = layoutMap[widgetId].h;
logger.debug(`Restoring widget ${widgetId} from saved layout: x=${x}, y=${y}, w=${w}, h=${h}`);
} else {
// Smart column placement - use the shorter column
if (leftColumnY <= rightColumnY) {
// Place in left column
x = 0;
y = leftColumnY;
leftColumnY = y + widgetHeight; // Update tracker
} else {
// Place in right column
x = 24;
y = rightColumnY;
rightColumnY = y + widgetHeight; // Update tracker
}
w = 24;
h = widgetHeight;
}
const widget = createWidget(widgetId, host.name, '<span data-lucide="server"></span>', {
x: x,
y: y,
w: w,
h: h,
minW: 3, minH: 3
});
});
}
function createWidget(id, title, icon, gridOptions) {
// Create DOM element instead of HTML string (GridStack v12+ requirement)
const widgetEl = document.createElement('div');
widgetEl.className = 'grid-stack-item';
widgetEl.setAttribute('data-widget-id', id);
widgetEl.innerHTML = `
<div class="grid-stack-item-content">
<div class="widget-header">
<div class="widget-title">
<span>${icon}</span>
<span>${title}</span>
</div>
</div>
<div class="widget-body" id="widget-${id}">
<!-- Content will be rendered here -->
</div>
</div>
`;
// GridStack v12+ API - use makeWidget with options
grid.makeWidget(widgetEl, {
x: gridOptions.x,
y: gridOptions.y,
w: gridOptions.w,
h: gridOptions.h,
minW: gridOptions.minW || 2,
minH: gridOptions.minH || 2,
maxH: gridOptions.maxH || undefined
});
initIcons();
return document.querySelector(`[data-widget-id="${id}"]`);
}
function renderDashboardWidgets() {
logger.debug('renderDashboardWidgets called - hosts:', hosts.length, 'grid:', !!grid);
// Check if we need to create/remove host widgets (hosts added or removed)
if (grid) {
const existingHostWidgets = grid.getGridItems().filter(item =>
item.getAttribute('data-widget-id')?.startsWith('host-')
);
// Call createHostWidgets if host count changed OR if we have hosts but no widgets
if (existingHostWidgets.length !== hosts.length || (hosts.length > 0 && existingHostWidgets.length === 0)) {
createHostWidgets();
}
}
// Render stats widget - only update values, not rebuild HTML
const statsWidget = document.getElementById('widget-stats');
if (statsWidget) {
// Check if stats grid exists, create if not
let statsGrid = statsWidget.querySelector('.stats-grid');
if (!statsGrid) {
statsWidget.innerHTML = `
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Hosts</div>
<div class="stat-value" data-stat="hosts">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Containers</div>
<div class="stat-value" data-stat="containers">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Running</div>
<div class="stat-value" data-stat="running">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Alert Rules</div>
<div class="stat-value" data-stat="alerts">0</div>
</div>
</div>
`;
statsGrid = statsWidget.querySelector('.stats-grid');
}
// Update only the values
const hostsValue = statsWidget.querySelector('[data-stat="hosts"]');
const containersValue = statsWidget.querySelector('[data-stat="containers"]');
const runningValue = statsWidget.querySelector('[data-stat="running"]');
const alertsValue = statsWidget.querySelector('[data-stat="alerts"]');
if (hostsValue) hostsValue.textContent = hosts.length;
if (containersValue) containersValue.textContent = containers.length;
if (runningValue) runningValue.textContent = containers.filter(c => c.state === 'running').length;
if (alertsValue) alertsValue.textContent = alertRules.length;
}
// Render individual host widgets
hosts.forEach(host => {
const hostWidget = document.getElementById(`widget-host-${host.id}`);
if (hostWidget) {
// Get containers for this host
let hostContainers = containers.filter(c => c.host_id === host.id);
// Apply global search filter
hostContainers = filterContainers(hostContainers);
// Apply global sort
hostContainers = sortContainers(hostContainers);
const maxContainersToShow = hostContainers.length; // Show all containers now that widgets are dynamically sized
// Check if container order or state has changed to avoid unnecessary re-renders
const containerStateKey = hostContainers.map(c => `${c.short_id}:${c.auto_restart}:${c.state}`).join(',');
const previousStateKey = hostWidget.dataset.containerState;
const showContainerStats = globalSettings.show_container_stats !== false; // Default to true
const containersList = hostContainers.slice(0, maxContainersToShow).map(container => `
<div class="container-item" data-status="${container.state}">
<div class="container-info" onclick="showContainerDetails('${container.host_id}', '${container.short_id}')">
<div class="container-icon container-${container.state}">
${getContainerIcon(container.state)}
</div>
<div class="container-details">
<div class="container-name"><span class="container-status-dot status-${container.state}"></span> ${escapeHtml(container.name)}</div>
<div class="container-id">${escapeHtml(container.short_id)}</div>
</div>
</div>
<div class="container-stats">
${showContainerStats && container.state === 'running' ? `
<div class="container-stats-charts">
<canvas id="container-cpu-${container.host_id}-${container.short_id}" width="35" height="12"></canvas>
<canvas id="container-ram-${container.host_id}-${container.short_id}" width="35" height="12"></canvas>
<canvas id="container-net-${container.host_id}-${container.short_id}" width="35" height="12"></canvas>
</div>
<div class="container-stats-values">
<div>CPU ${container.cpu_percent ? container.cpu_percent.toFixed(1) : '0'}%</div>
<div>RAM ${container.memory_usage ? formatBytes(container.memory_usage) : '0 B'}</div>
<div id="container-net-value-${container.host_id}-${container.short_id}">NET 0 B/s</div>
</div>
` : ''}
</div>
<div class="container-actions">
<div class="auto-restart-toggle ${container.auto_restart ? 'enabled' : ''}">
<i data-lucide="rotate-cw" style="width:14px;height:14px;"></i>
<div class="toggle-switch ${container.auto_restart ? 'active' : ''}"
onclick="toggleAutoRestart('${container.host_id}', '${container.short_id}', event)"></div>
</div>
<span class="container-state ${getStateClass(container.state)}">
${container.state}
</span>
</div>
</div>
`).join('');
const moreCount = hostContainers.length > maxContainersToShow ? hostContainers.length - maxContainersToShow : 0;
// Update widget title with status badge and metrics
const widgetHeader = hostWidget.closest('.grid-stack-item').querySelector('.widget-header');
if (widgetHeader) {
const showMetrics = globalSettings.show_host_stats !== false; // Default to true
const metricsExist = widgetHeader.querySelector('.host-metrics');
// If metrics visibility changed, rebuild the entire header
if ((showMetrics && !metricsExist) || (!showMetrics && metricsExist)) {
// If hiding metrics, clean up Chart.js instances first
if (!showMetrics && metricsExist) {
if (typeof removeHostMetrics === 'function') {
removeHostMetrics(host.id);
}
}
widgetHeader.innerHTML = `
<div class="widget-title">
<span><i data-lucide="server" style="width:16px;height:16px;"></i></span>
<span><span class="host-status-dot status-${host.status}" title="${host.status}"></span> ${host.name}</span>
</div>
${showMetrics ? `
<div class="host-metrics">
<div class="metric-sparkline">
<canvas id="cpu-chart-${host.id}" width="60" height="20"></canvas>
<div class="metric-label">CPU: <span id="cpu-value-${host.id}">0%</span></div>
</div>
<div class="metric-sparkline">
<canvas id="ram-chart-${host.id}" width="60" height="20"></canvas>
<div class="metric-label">RAM: <span id="ram-value-${host.id}">0%</span></div>
</div>
<div class="metric-sparkline">
<canvas id="net-chart-${host.id}" width="60" height="20"></canvas>
<div class="metric-label">NET: <span id="net-value-${host.id}">0 B/s</span></div>
</div>
</div>
` : ''}
`;
// Create charts after DOM is updated (only if showing metrics)
if (showMetrics) {
createHostMetricsCharts(host.id);
}
} else if (metricsExist) {
// Metrics already exist and should stay - just update the title/status
const titleElement = widgetHeader.querySelector('.widget-title');
if (titleElement) {
titleElement.innerHTML = `
<span><i data-lucide="server" style="width:16px;height:16px;"></i></span>
<span><span class="host-status-dot status-${host.status}" title="${host.status}"></span> ${host.name}</span>
`;
}
}
}
// Check if container stats visibility changed (independently of container state)
const previousShowStats = hostWidget.dataset.showContainerStats !== 'false';
const statsVisibilityChanged = previousShowStats !== showContainerStats;
// Only update if the container state has changed, stats visibility changed, or it's the first render
if (containerStateKey !== previousStateKey || statsVisibilityChanged) {
hostWidget.dataset.containerState = containerStateKey;
// Check if we're hiding container stats - cleanup charts for all containers on this host
if (previousShowStats && !showContainerStats) {
// Clean up all container charts for this host
hostContainers.forEach(container => {
if (typeof removeContainerMetrics === 'function') {
removeContainerMetrics(container.host_id, container.short_id);
}
});
}
hostWidget.dataset.showContainerStats = showContainerStats;
hostWidget.innerHTML = `
<div class="container-list">
${containersList || '<div style="padding: 12px; color: var(--text-tertiary); text-align: center;">No containers</div>'}
${moreCount > 0 ? `<div style="padding: 8px 12px; font-size: 12px; color: var(--text-tertiary); text-align: center; border-top: 1px solid var(--border);">+${moreCount} more containers</div>` : ''}
</div>
`;
// Create sparklines for running containers
if (showContainerStats) {
hostContainers.slice(0, maxContainersToShow).forEach(container => {
if (container.state === 'running') {
createContainerSparklines(container.host_id, container.short_id);
// Update sparklines with current data
updateContainerSparklines(container.host_id, container.short_id, container);
}
});
}
} else {
// Just update the sparkline data without re-rendering
if (showContainerStats) {
hostContainers.slice(0, maxContainersToShow).forEach(container => {
if (container.state === 'running') {
updateContainerSparklines(container.host_id, container.short_id, container);
}
});
}
}
}
});
}
function saveDashboardLayoutManual() {
saveDashboardLayout();
showToast('✅ Dashboard layout saved');
}
function removeWidget(id) {
const widget = document.querySelector(`[data-widget-id="${id}"]`);
if (widget) {
grid.removeWidget(widget);
}
}
async function saveDashboardLayout() {
const layout = grid.save(false);
const gridItems = grid.getGridItems();
// Create a map of grid items by their grid position to ensure correct mapping
const layoutWithIds = [];
for (const gridItem of gridItems) {
const widgetId = gridItem.getAttribute('data-widget-id');
const gridData = gridItem.gridstackNode;
if (widgetId && gridData) {
layoutWithIds.push({
id: widgetId,
x: gridData.x,
y: gridData.y,
w: gridData.w,
h: gridData.h
});
}
}
// Ensure stats widget is always in the saved layout
const hasStatsWidget = layoutWithIds.some(item => item.id === 'stats');
if (!hasStatsWidget) {
logger.debug('Adding missing stats widget to saved layout');
layoutWithIds.unshift({
id: 'stats',
x: 0,
y: 0,
w: 48,
h: 9
});
}
logger.debug('Saving layout:', layoutWithIds.map(w => `${w.id} (${w.x},${w.y}) h=${w.h}`));
// Save to API
try {
const response = await fetch('/api/user/dashboard-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ layout: JSON.stringify(layoutWithIds) })
});
if (!response.ok) {
logger.error('Failed to save dashboard layout to API:', response.status);
showToast('⚠️ Failed to save dashboard layout', 'error');
}
} catch (error) {
logger.error('Error saving dashboard layout:', error);
showToast('⚠️ Failed to save dashboard layout', 'error');
}
}
function loadDashboardLayout(layout) {
// Remove duplicate stats widgets from saved layout (keep only the first one)
const seenIds = new Set();
const dedupedLayout = layout.filter(item => {
if (item.id === 'stats' && seenIds.has('stats')) {
logger.debug('Removing duplicate stats widget from saved layout');
return false;
}
seenIds.add(item.id);
return true;
});
// Ensure stats widget is always present first
const hasStatsWidget = dedupedLayout.some(item => item.id === 'stats');
if (!hasStatsWidget) {
logger.debug('Stats widget missing from layout - adding it back');
createWidget('stats', 'Statistics', '<span data-lucide="bar-chart-3"></span>', {
x: 0, y: 0, w: 48, h: 9,
minW: 48, minH: 9, maxH: 9, maxW: 48,
noResize: true,
noMove: true
});
}
// Sort layout for proper reading order (left-to-right, top-to-bottom)
// This ensures mobile displays hosts in the same priority order as desktop
const sortedLayout = [...dedupedLayout].sort((a, b) => {
// Stats widget always first
if (a.id === 'stats') return -1;
if (b.id === 'stats') return 1;
// Sort by Y position first (row), then X position (column)
if (Math.abs(a.y - b.y) < 5) { // Same row (within 5 units)
return a.x - b.x; // Left to right
}
return a.y - b.y; // Top to bottom
});
sortedLayout.forEach(item => {
const widgetConfig = getWidgetConfig(item.id);
if (widgetConfig) {
// Special handling for stats widget to ensure proper positioning
if (item.id === 'stats') {
createWidget(widgetConfig.id, widgetConfig.title, widgetConfig.icon, {
x: 0, y: 0, w: 48, h: 9,
minW: 48, minH: 9, maxH: 9, maxW: 48,
noResize: true,
noMove: true
});
} else {
createWidget(widgetConfig.id, widgetConfig.title, widgetConfig.icon, {
x: item.x,
y: item.y,
w: item.w,
h: item.h,
minW: widgetConfig.minW,
minH: widgetConfig.minH,
maxH: widgetConfig.maxH
});
}
} else {
logger.debug(`Skipping widget ${item.id} - config not available yet (hosts data may not be loaded)`);
}
});
}
function getWidgetConfig(id) {
const configs = {
'stats': { id: 'stats', title: 'Statistics', icon: '<span data-lucide="bar-chart-3"></span>', minW: 48, minH: 9, maxH: 9, maxW: 48, noResize: true, noMove: true }
};
// Dynamic configs for host widgets
if (id.startsWith('host-')) {
const hostId = id.replace('host-', '');
const host = hosts.find(h => h.id === hostId);
if (host) {
return { id: id, title: host.name, icon: '<i data-lucide="monitor" style="width:16px;height:16px;"></i>', minW: 3, minH: 3 };
}
}
return configs[id];
}
async function resetDashboardLayout() {
// Reload the saved layout from API, or use default if none exists
try {
const response = await fetch('/api/user/dashboard-layout', {
method: 'GET',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.layout) {
// Revert to last saved layout
grid.removeAll();
loadDashboardLayout(JSON.parse(data.layout));
showToast('🔄 Dashboard layout reset to last saved state');
} else {
// No saved layout, use default
grid.removeAll();
createDefaultDashboard();
showToast('🔄 Dashboard layout reset to default');
}
} else {
logger.error('Failed to load dashboard layout from API:', response.status);
showToast('⚠️ Failed to load dashboard layout', 'error');
}
} catch (error) {
logger.error('Error loading dashboard layout:', error);
showToast('⚠️ Failed to load dashboard layout', 'error');
}
}
// ============================================================================
// Container Search and Sorting
// ============================================================================
// Global filter state
let containerSearchTerm = '';
let containerSortOption = 'name-asc';
// Initialize search/sort from localStorage (search) and database (sort)
async function initializeContainerFilters() {
// Search: from localStorage (session-specific)
const savedSearch = localStorage.getItem('dockmon_container_search') || '';
containerSearchTerm = savedSearch;
const searchInput = document.getElementById('containerSearch');
if (searchInput) searchInput.value = savedSearch;
// Sort: from database (cross-browser preference)
try {
const response = await fetch(`${API_BASE}/api/user/container-sort-order`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
containerSortOption = data.sort_order || 'name-asc';
} else {
containerSortOption = 'name-asc'; // Fallback
}
} catch (error) {
logger.error('Error loading container sort preference:', error);
containerSortOption = 'name-asc'; // Fallback
}
const sortSelect = document.getElementById('containerSort');
if (sortSelect) sortSelect.value = containerSortOption;
}
// Apply search and sort filters to containers
async function applyContainerFilters() {
const searchInput = document.getElementById('containerSearch');
const sortSelect = document.getElementById('containerSort');
// Update search term (localStorage)
if (searchInput) {
containerSearchTerm = searchInput.value;
localStorage.setItem('dockmon_container_search', containerSearchTerm);
}
// Update sort option (database)
if (sortSelect && sortSelect.value !== containerSortOption) {
const newSortOption = sortSelect.value;
try {
const response = await fetch(`${API_BASE}/api/user/container-sort-order`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sort_order: newSortOption })
});
if (response.ok) {
containerSortOption = newSortOption;
} else {
logger.error('Failed to save container sort preference');
// Revert dropdown to previous value
sortSelect.value = containerSortOption;
showToast('⚠️ Failed to save sort preference', 'error');
return;
}
} catch (error) {
logger.error('Error saving container sort preference:', error);
// Revert dropdown to previous value
sortSelect.value = containerSortOption;
showToast('⚠️ Failed to save sort preference', 'error');
return;
}
}
// Re-render dashboard with filters applied
renderDashboardWidgets();
}
// Filter containers by search term (supports regex)
function filterContainers(containersList) {
if (!containerSearchTerm || containerSearchTerm.trim() === '') {
return containersList;
}
const searchTerm = containerSearchTerm.trim();
// Try to use as regex first, fall back to plain string search
try {
const regex = new RegExp(searchTerm, 'i');
return containersList.filter(c =>
regex.test(c.name) || regex.test(c.image)
);
} catch (e) {
// Invalid regex, use plain string search
const lowerSearch = searchTerm.toLowerCase();
return containersList.filter(c =>
c.name.toLowerCase().includes(lowerSearch) ||
c.image.toLowerCase().includes(lowerSearch)
);
}
}
// Sort containers based on selected option
function sortContainers(containersList) {
const sorted = [...containersList]; // Create copy to avoid mutating original
switch (containerSortOption) {
case 'name-asc':
return sorted.sort((a, b) => a.name.localeCompare(b.name));
case 'name-desc':
return sorted.sort((a, b) => b.name.localeCompare(a.name));
case 'status':
// Running > paused > created > restarting > exited > dead
const statusPriority = {
'running': 1,
'paused': 2,
'created': 3,
'restarting': 4,
'exited': 5,
'dead': 6
};
return sorted.sort((a, b) => {
const priorityA = statusPriority[a.state] || 99;
const priorityB = statusPriority[b.state] || 99;
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// Same status, sort by name
return a.name.localeCompare(b.name);
});
case 'memory-desc':
return sorted.sort((a, b) => (b.memory_usage || 0) - (a.memory_usage || 0));
case 'memory-asc':
return sorted.sort((a, b) => (a.memory_usage || 0) - (b.memory_usage || 0));
case 'cpu-desc':
return sorted.sort((a, b) => (b.cpu_percent || 0) - (a.cpu_percent || 0));
case 'cpu-asc':
return sorted.sort((a, b) => (a.cpu_percent || 0) - (b.cpu_percent || 0));
default:
return sorted;
}
}

471
dockmon/src/js/events.js Normal file
View File

@@ -0,0 +1,471 @@
// ==================== Event Log Functions ====================
let currentEventsPage = 0;
const eventsPerPage = 50;
let eventSearchTimeout = null;
let currentSortOrder = 'desc'; // 'desc' = newest first, 'asc' = oldest first
// Multi-select dropdown functions
function toggleMultiselect(filterId) {
const multiselect = document.getElementById(`${filterId}Multiselect`);
const wasOpen = multiselect.classList.contains('open');
// Close all other multiselects
document.querySelectorAll('.multiselect').forEach(ms => ms.classList.remove('open'));
// Toggle this one
if (!wasOpen) {
multiselect.classList.add('open');
}
// Re-create icons after DOM manipulation
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
function updateMultiselect(filterId) {
const dropdown = document.getElementById(`${filterId}Dropdown`);
const checkboxes = dropdown.querySelectorAll('input[type="checkbox"]:checked');
const selected = Array.from(checkboxes).map(cb => cb.value);
// Update the label
const label = document.querySelector(`#${filterId}Multiselect .multiselect-label`);
if (selected.length === 0) {
if (filterId === 'eventCategory') label.textContent = 'All Categories';
else if (filterId === 'eventSeverity') label.textContent = 'All Severities';
else if (filterId === 'eventHost') label.textContent = 'All Hosts';
} else if (selected.length === 1) {
label.textContent = selected[0].charAt(0).toUpperCase() + selected[0].slice(1);
} else {
label.textContent = `${selected.length} selected`;
}
// Trigger filter update
filterEvents();
}
function getMultiselectValues(filterId) {
const dropdown = document.getElementById(`${filterId}Dropdown`);
const checkboxes = dropdown.querySelectorAll('input[type="checkbox"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
// Close multiselects when clicking outside
document.addEventListener('click', function(event) {
if (!event.target.closest('.multiselect')) {
document.querySelectorAll('.multiselect').forEach(ms => ms.classList.remove('open'));
}
});
async function loadEvents() {
const eventsList = document.getElementById('eventsList');
if (!eventsList) return;
eventsList.innerHTML = '<div class="events-loading">Loading events...</div>';
try {
// Build query parameters
const timeRange = document.getElementById('eventTimeRange').value;
const categories = getMultiselectValues('eventCategory');
const severities = getMultiselectValues('eventSeverity');
const hostIds = getMultiselectValues('eventHost');
const search = document.getElementById('eventSearch').value;
const params = new URLSearchParams({
limit: eventsPerPage,
offset: currentEventsPage * eventsPerPage
});
if (timeRange !== 'all') {
params.append('hours', timeRange);
}
// Append multiple values for multi-select filters
categories.forEach(cat => params.append('category', cat));
severities.forEach(sev => params.append('severity', sev));
hostIds.forEach(host => params.append('host_id', host));
if (search) params.append('search', search);
const response = await fetch(`/api/events?${params}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to load events');
}
const data = await response.json();
renderEvents(data);
} catch (error) {
logger.error('Error loading events:', error);
eventsList.innerHTML = '<div class="events-empty">Failed to load events. Please try again.</div>';
showToast('Failed to load events', 'error');
}
}
function renderEvents(data) {
const eventsList = document.getElementById('eventsList');
const eventsPagination = document.getElementById('eventsPagination');
if (!data.events || data.events.length === 0) {
eventsList.innerHTML = `
<div class="events-empty">
<span data-lucide="inbox"></span>
<p>No events found</p>
</div>
`;
lucide.createIcons();
eventsPagination.innerHTML = '';
return;
}
// Render events
let html = '';
data.events.forEach(event => {
html += renderEventItem(event);
});
eventsList.innerHTML = html;
lucide.createIcons();
// Render pagination
const start = currentEventsPage * eventsPerPage + 1;
const end = Math.min((currentEventsPage + 1) * eventsPerPage, data.total_count);
eventsPagination.innerHTML = `
<div class="pagination-info">
Showing ${start}-${end} of ${data.total_count} events
</div>
<div class="pagination-controls">
<button class="pagination-btn" ${currentEventsPage === 0 ? 'disabled' : ''} onclick="previousEventsPage()">
<span data-lucide="chevron-left"></span> Previous
</button>
<button class="pagination-btn" ${!data.has_more ? 'disabled' : ''} onclick="nextEventsPage()">
Next <span data-lucide="chevron-right"></span>
</button>
</div>
`;
lucide.createIcons();
}
function renderEventItem(event) {
// Ensure timestamp is treated as UTC if no timezone info
let timestampStr = event.timestamp;
if (!timestampStr.includes('+') && !timestampStr.endsWith('Z')) {
timestampStr += 'Z'; // Treat as UTC
}
const timestamp = new Date(timestampStr).toLocaleString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
});
// Build inline metadata
let metaParts = [];
if (event.container_name) {
metaParts.push(`container=${escapeHtml(event.container_name)}`);
}
if (event.host_name) {
metaParts.push(`host=${escapeHtml(event.host_name)}`);
}
// Don't show user=user since we already show (user action) in the event text
// if (event.triggered_by && event.triggered_by !== 'system') {
// metaParts.push(`user=${escapeHtml(event.triggered_by)}`);
// }
const metaString = metaParts.length > 0 ? metaParts.join(' ') : '';
// Build the event text - show message if it's different from title
let eventText = escapeHtml(event.title);
if (event.message && event.message !== event.title) {
// Extract the useful part of the message (e.g., "from running to exited")
const stateChangeMatch = event.message.match(/state changed from (\w+) to (\w+)/i);
if (stateChangeMatch) {
const fromState = stateChangeMatch[1];
const toState = stateChangeMatch[2];
const userAction = event.message.includes('(user action)') ? ' <span style="color: var(--text-secondary);">(user action)</span>' : '';
eventText += ` from <span class="state-${fromState}">${escapeHtml(fromState)}</span> to <span class="state-${toState}">${escapeHtml(toState)}</span>${userAction}`;
} else {
// For other messages, show full message instead of title
eventText = escapeHtml(event.message);
}
}
return `
<div class="event-line severity-${event.severity}">
<span class="event-timestamp">${timestamp}</span>
<span class="event-severity-dot"></span>
<span class="event-level">level=${event.severity}</span>
<span class="event-message-text">${eventText}</span>
${metaString ? `<span class="event-meta-inline">${metaString}</span>` : ''}
<div class="event-mobile-content">
<div class="event-mobile-header"><span class="event-severity-dot"></span><span class="event-mobile-time">${timestamp}</span></div>
<div class="event-mobile-message">${eventText}</div>
</div>
</div>
`;
}
// getCategoryIcon() removed - unused function
function formatEventTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
// showEventDetails() removed - unused function (TODO was never implemented)
function refreshEvents() {
currentEventsPage = 0;
loadEvents();
}
function filterEvents() {
currentEventsPage = 0;
loadEvents();
}
function debounceEventSearch() {
clearTimeout(eventSearchTimeout);
eventSearchTimeout = setTimeout(() => {
filterEvents();
}, 500);
}
function nextEventsPage() {
currentEventsPage++;
loadEvents();
}
function previousEventsPage() {
if (currentEventsPage > 0) {
currentEventsPage--;
loadEvents();
}
}
async function populateEventHostFilter() {
const hostDropdown = document.getElementById('eventHostDropdown');
if (!hostDropdown) return;
// Clear existing checkboxes
hostDropdown.innerHTML = '';
// Add hosts from the global hosts array
hosts.forEach(host => {
const label = document.createElement('label');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = host.id;
checkbox.onchange = () => updateMultiselect('eventHost');
label.appendChild(checkbox);
label.appendChild(document.createTextNode(' ' + host.name));
hostDropdown.appendChild(label);
});
}
async function loadEventSortOrder() {
try {
const response = await fetch('/api/user/event-sort-order', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
currentSortOrder = data.sort_order || 'desc';
updateSortOrderButton();
}
} catch (error) {
logger.error('Failed to load sort order preference:', error);
}
}
async function toggleEventSortOrder() {
// Toggle between asc and desc
currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc';
// Save to backend
try {
await fetch('/api/user/event-sort-order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sort_order: currentSortOrder })
});
} catch (error) {
logger.error('Failed to save sort order:', error);
showToast('Failed to save sort order', 'error');
}
// Update button
updateSortOrderButton();
// Reload events with new sort order
currentEventsPage = 0;
loadEvents();
}
function updateSortOrderButton() {
const btn = document.getElementById('sortOrderBtn');
if (!btn) return;
if (currentSortOrder === 'desc') {
btn.innerHTML = '<span data-lucide="arrow-down-wide-narrow"></span> Newest First';
} else {
btn.innerHTML = '<span data-lucide="arrow-up-narrow-wide"></span> Oldest First';
}
lucide.createIcons();
}
// WebSocket handling for real-time event updates
let isOnEventsPage = false;
// Register a custom handler using the wsMessageHandlers array instead of overriding the function
if (!window.wsMessageHandlers) {
window.wsMessageHandlers = [];
}
window.wsMessageHandlers.push(function(data) {
// Only process new_event messages when on the events page
if (data.type === 'new_event' && isOnEventsPage) {
const event = data.event;
// Check if event matches current filters
if (shouldShowEvent(event)) {
prependNewEvent(event);
}
}
});
function shouldShowEvent(event) {
// Check if event matches current filters
const categories = getMultiselectValues('eventCategory');
const severities = getMultiselectValues('eventSeverity');
const hostIds = getMultiselectValues('eventHost');
const search = document.getElementById('eventSearch')?.value;
// Category filter - if any categories selected, event must match one
if (categories.length > 0 && !categories.includes(event.category)) return false;
// Severity filter - if any severities selected, event must match one
if (severities.length > 0 && !severities.includes(event.severity)) return false;
// Host filter - if any hosts selected, event must match one
if (hostIds.length > 0 && !hostIds.includes(event.host_id)) return false;
// Search filter (supports regex)
if (search) {
const searchTerm = search.trim();
// Try to use as regex first, fall back to plain string search
try {
const regex = new RegExp(searchTerm, 'i');
const titleMatch = event.title && regex.test(event.title);
const messageMatch = event.message && regex.test(event.message);
const containerMatch = event.container_name && regex.test(event.container_name);
if (!titleMatch && !messageMatch && !containerMatch) return false;
} catch (e) {
// Invalid regex, use plain string search
const searchLower = searchTerm.toLowerCase();
const titleMatch = event.title?.toLowerCase().includes(searchLower);
const messageMatch = event.message?.toLowerCase().includes(searchLower);
const containerMatch = event.container_name?.toLowerCase().includes(searchLower);
if (!titleMatch && !messageMatch && !containerMatch) return false;
}
}
return true;
}
function prependNewEvent(event) {
const eventsList = document.getElementById('eventsList');
if (!eventsList) return;
// Check if we're showing empty state
const emptyState = eventsList.querySelector('.events-empty');
if (emptyState) {
emptyState.remove();
}
// Create new event HTML
const eventHtml = renderEventItem(event);
// Add to top or bottom depending on sort order
const tempDiv = document.createElement('div');
tempDiv.innerHTML = eventHtml;
const eventElement = tempDiv.firstElementChild;
eventElement.style.opacity = '0';
if (currentSortOrder === 'desc') {
// Newest first - add to top
eventElement.style.transform = 'translateY(-10px)';
eventsList.insertBefore(eventElement, eventsList.firstChild);
} else {
// Oldest first - add to bottom
eventElement.style.transform = 'translateY(10px)';
eventsList.appendChild(eventElement);
}
// Trigger animation
setTimeout(() => {
eventElement.style.transition = 'all 0.3s ease';
eventElement.style.opacity = '1';
eventElement.style.transform = 'translateY(0)';
}, 10);
// Re-render icons
lucide.createIcons();
// Show a subtle notification
const badge = document.createElement('div');
badge.style.cssText = 'position: fixed; top: 80px; right: 20px; background: var(--primary); color: white; padding: 8px 16px; border-radius: 6px; font-size: 14px; z-index: 10000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); animation: slideIn 0.3s ease;';
badge.innerHTML = '<span data-lucide="bell"></span> New event';
document.body.appendChild(badge);
lucide.createIcons();
setTimeout(() => {
badge.style.opacity = '0';
badge.style.transition = 'opacity 0.3s ease';
setTimeout(() => badge.remove(), 300);
}, 2000);
}
// Hook into the existing WebSocket connection
// The websocket is initialized in app.js and messages are handled globally
// Hook into page switching to load events
document.addEventListener('DOMContentLoaded', function() {
const originalSwitchPage = window.switchPage;
if (originalSwitchPage) {
window.switchPage = function(page) {
originalSwitchPage(page);
if (page === 'events') {
isOnEventsPage = true;
loadEventSortOrder();
populateEventHostFilter();
loadEvents();
} else {
isOnEventsPage = false;
}
};
}
});

68
dockmon/src/js/logger.js Normal file
View File

@@ -0,0 +1,68 @@
/**
* DockMon Logging Utility
* Centralized logging with environment-based levels
*/
const LogLevel = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
NONE: 4
};
class Logger {
constructor() {
// Set log level based on environment or default to INFO for production
this.level = this.getLogLevel();
}
getLogLevel() {
// Default to WARN level (only show warnings and errors)
return LogLevel.WARN;
}
debug(...args) {
if (this.level <= LogLevel.DEBUG) {
console.log('[DEBUG]', ...args);
}
}
info(...args) {
if (this.level <= LogLevel.INFO) {
console.info('[INFO]', ...args);
}
}
warn(...args) {
if (this.level <= LogLevel.WARN) {
console.warn('[WARN]', ...args);
}
}
error(...args) {
if (this.level <= LogLevel.ERROR) {
console.error('[ERROR]', ...args);
}
}
// For WebSocket/Network debugging
ws(...args) {
if (this.level <= LogLevel.DEBUG) {
console.log('[WS]', ...args);
}
}
// For API debugging
api(...args) {
if (this.level <= LogLevel.DEBUG) {
console.log('[API]', ...args);
}
}
}
// Create singleton instance
const logger = new Logger();
// Make available globally
window.logger = logger;

539
dockmon/src/js/logs.js Normal file
View File

@@ -0,0 +1,539 @@
// logs.js - Container Logs Viewer with multi-container support
let selectedContainers = []; // Array of {hostId, containerId, name}
let logsPaused = false;
let logsPollingInterval = null;
let allLogs = []; // Consolidated logs from all containers
let containerColorMap = {}; // Map container IDs to color indices
let nextColorIndex = 0;
let logsSortOrder = 'desc'; // 'asc' or 'desc'
let isFetchingLogs = false; // Prevent concurrent fetch calls
let isRestoringSelection = false; // Prevent onchange during dropdown rebuild
let pendingSelectionChange = false; // Track if selection changed while dropdown was open
// Initialize logs page when switched to
function initLogsPage() {
// Wait a bit for hosts data to load if needed
if (!window.hosts || window.hosts.length === 0) {
setTimeout(() => {
populateContainerList();
}, 500);
} else {
populateContainerList();
}
stopLogsPolling(); // Stop any existing polling
loadLogsSortOrder();
}
// Load sort order from localStorage
function loadLogsSortOrder() {
const saved = localStorage.getItem('logsSortOrder');
if (saved) {
logsSortOrder = saved;
updateLogsSortButton();
}
}
// Save sort order to localStorage
function saveLogsSortOrder() {
localStorage.setItem('logsSortOrder', logsSortOrder);
}
// Populate container list for multi-select
function populateContainerList() {
const dropdown = document.getElementById('logsContainerDropdown');
if (!dropdown) {
logger.error('Dropdown element not found');
return;
}
// Save current selection state before rebuilding
const selectedKeys = selectedContainers.map(c => `${c.hostId}:${c.containerId}`);
// Prevent onchange events during restoration
isRestoringSelection = true;
dropdown.innerHTML = '';
// Get hosts and containers from global arrays (populated by core.js via WebSocket)
if (!window.hosts || window.hosts.length === 0) {
dropdown.innerHTML = '<div style="padding: 12px; color: var(--text-tertiary);">No hosts available. Make sure you have hosts configured.</div>';
return;
}
if (!window.containers || window.containers.length === 0) {
dropdown.innerHTML = '<div style="padding: 12px; color: var(--text-tertiary);">No containers available. Make sure your hosts have running containers.</div>';
return;
}
// Group containers by host_id
const containersByHost = {};
window.containers.forEach(container => {
if (!containersByHost[container.host_id]) {
containersByHost[container.host_id] = [];
}
containersByHost[container.host_id].push(container);
});
let totalContainers = 0;
// Sort hosts by dashboard widget position (left-to-right, top-to-bottom)
// Get widget positions from GridStack if available
let sortedHosts = [...window.hosts];
if (typeof grid !== 'undefined' && grid) {
const widgetPositions = {};
grid.getGridItems().forEach(widget => {
const widgetId = widget.getAttribute('data-widget-id');
if (widgetId && widgetId.startsWith('host-')) {
const hostId = widgetId.replace('host-', '');
const gridData = widget.gridstackNode;
if (gridData) {
widgetPositions[hostId] = { y: gridData.y, x: gridData.x };
}
}
});
// Sort by y (row) first, then x (column)
sortedHosts.sort((a, b) => {
const posA = widgetPositions[a.id];
const posB = widgetPositions[b.id];
// If no position data, put at end
if (!posA && !posB) return 0;
if (!posA) return 1;
if (!posB) return -1;
// Sort by row (y), then column (x)
if (posA.y !== posB.y) {
return posA.y - posB.y;
}
return posA.x - posB.x;
});
}
// Iterate through hosts and show their containers
sortedHosts.forEach(host => {
const hostContainers = containersByHost[host.id] || [];
if (hostContainers.length === 0) return;
// Sort containers alphabetically by name (same as dashboard)
hostContainers.sort((a, b) => a.name.localeCompare(b.name));
totalContainers += hostContainers.length;
// Add host header
const hostHeader = document.createElement('div');
hostHeader.style.padding = '8px 12px';
hostHeader.style.fontWeight = '600';
hostHeader.style.color = 'var(--text-secondary)';
hostHeader.style.fontSize = '12px';
hostHeader.style.borderBottom = '1px solid var(--border)';
hostHeader.textContent = host.name;
dropdown.appendChild(hostHeader);
// Add containers for this host
hostContainers.forEach(container => {
const label = document.createElement('label');
label.style.display = 'block';
label.style.padding = '8px 12px';
label.style.cursor = 'pointer';
// Determine status color and symbol
let statusColor, statusSymbol;
if (container.state === 'running') {
statusColor = '#22c55e'; // Green
statusSymbol = '●';
} else if (container.state === 'exited') {
statusColor = '#ef4444'; // Red
statusSymbol = '●';
} else {
statusColor = '#6b7280'; // Grey
statusSymbol = '○';
}
const containerKey = `${host.id}:${container.id}`;
const isSelected = selectedKeys.includes(containerKey);
label.innerHTML = `
<input type="checkbox"
value="${containerKey}"
${isSelected ? 'checked' : ''}
onchange="updateContainerSelection()">
<span style="margin-left: 8px;">${container.name}</span>
<span style="margin-left: 8px; color: ${statusColor}; font-size: 11px;">
${statusSymbol}
</span>
`;
dropdown.appendChild(label);
});
});
if (totalContainers === 0) {
dropdown.innerHTML = '<div style="padding: 12px; color: var(--text-tertiary);">No containers available. Make sure your hosts have running containers.</div>';
}
// Re-enable onchange events after restoration is complete
setTimeout(() => {
isRestoringSelection = false;
}, 0);
}
// Update selected containers and fetch logs immediately
function updateContainerSelection() {
// Skip if we're just restoring selection during dropdown rebuild
if (isRestoringSelection) {
return;
}
// Update UI immediately (label and selection state)
const dropdown = document.getElementById('logsContainerDropdown');
const checkboxes = dropdown.querySelectorAll('input[type="checkbox"]:checked');
// Enforce 15 container limit to protect API
if (checkboxes.length > 15) {
showToast('⚠️ Maximum 15 containers can be selected at once', 'warning');
// Uncheck the last selected checkbox
checkboxes[checkboxes.length - 1].checked = false;
return;
}
selectedContainers = Array.from(checkboxes).map(cb => {
const [hostId, containerId] = cb.value.split(':');
const containersData = window.containers || [];
const container = containersData.find(c => c.id === containerId && c.host_id === hostId);
return {
hostId,
containerId,
name: container?.name || 'Unknown'
};
});
// Update label
const label = document.querySelector('#logsContainerMultiselect .multiselect-label');
if (selectedContainers.length === 0) {
label.textContent = 'Select containers to view logs...';
} else if (selectedContainers.length === 1) {
label.textContent = selectedContainers[0].name;
} else {
label.textContent = `${selectedContainers.length} containers selected`;
}
// Assign colors to containers
selectedContainers.forEach((container, index) => {
const key = `${container.hostId}:${container.containerId}`;
if (!(key in containerColorMap)) {
containerColorMap[key] = nextColorIndex % 8;
nextColorIndex++;
}
});
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Reload logs immediately (no rate limiting on backend for authenticated users)
reloadLogs();
}
// Reload logs from scratch
async function reloadLogs() {
stopLogsPolling();
allLogs = [];
if (selectedContainers.length === 0) {
showLogsPlaceholder();
return;
}
await fetchLogs();
startLogsPolling();
}
// Fetch logs from all selected containers
async function fetchLogs() {
if (selectedContainers.length === 0 || logsPaused) return;
// Prevent concurrent fetches
if (isFetchingLogs) {
logger.debug('[LOGS] Fetch already in progress, skipping...');
return;
}
isFetchingLogs = true;
logger.debug('[LOGS] Starting fetch for', selectedContainers.length, 'containers');
const tailCount = document.getElementById('logsTailCount').value;
const tail = tailCount === 'all' ? 10000 : parseInt(tailCount);
let rateLimitHit = false;
try {
// Fetch logs from all containers with staggered delays to avoid rate limiting
const promises = selectedContainers.map(async (container, index) => {
// Add 100ms delay between each request to spread them out
if (index > 0) {
await new Promise(resolve => setTimeout(resolve, index * 100));
}
try {
const response = await fetch(
`${API_BASE}/api/hosts/${container.hostId}/containers/${container.containerId}/logs?tail=${tail}`
);
if (response.status === 429) {
rateLimitHit = true;
return [];
}
if (!response.ok) return [];
const data = await response.json();
// Add container info to each log line
return (data.logs || []).map(log => ({
...log,
containerName: container.name,
containerKey: `${container.hostId}:${container.containerId}`
}));
} catch (error) {
logger.error(`Error fetching logs for ${container.name}:`, error);
return [];
}
});
const logsArrays = await Promise.all(promises);
// If rate limit was hit, pause auto-refresh and notify user
if (rateLimitHit) {
stopLogsPolling();
const autoRefreshCheckbox = document.getElementById('logsAutoRefresh');
if (autoRefreshCheckbox) {
autoRefreshCheckbox.checked = false;
}
showToast('⚠️ Rate limit reached. Auto-refresh paused. Try selecting fewer containers or wait a moment.', 'error');
}
// Merge all logs
const newLogs = logsArrays.flat();
// Sort by timestamp (based on sort order)
newLogs.sort((a, b) => {
const aTime = new Date(a.timestamp);
const bTime = new Date(b.timestamp);
return logsSortOrder === 'asc' ? aTime - bTime : bTime - aTime;
});
allLogs = newLogs;
renderLogs();
} catch (error) {
logger.error('[LOGS] Error fetching logs:', error);
} finally {
isFetchingLogs = false;
logger.debug('[LOGS] Fetch completed');
}
}
// Render logs to the container
function renderLogs() {
const container = document.getElementById('logsContainer');
if (!container) return;
if (allLogs.length === 0) {
container.innerHTML = '<div class="logs-placeholder"><p>No logs available</p></div>';
return;
}
const showTimestamps = document.getElementById('logsTimestamps').checked;
const searchInput = document.getElementById('logsSearchInput').value;
// Filter logs by search term (supports regex)
let filteredLogs = allLogs;
if (searchInput && searchInput.trim() !== '') {
const searchTerm = searchInput.trim();
// Try to use as regex first, fall back to plain string search
try {
const regex = new RegExp(searchTerm, 'i');
filteredLogs = allLogs.filter(log => regex.test(log.log));
} catch (e) {
// Invalid regex, use plain string search
const searchLower = searchTerm.toLowerCase();
filteredLogs = allLogs.filter(log => log.log.toLowerCase().includes(searchLower));
}
}
// Build HTML
let html = '';
filteredLogs.forEach(log => {
const colorIndex = containerColorMap[log.containerKey] || 0;
const timestamp = new Date(log.timestamp).toLocaleString('en-US', {
month: '2-digit',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
});
html += `<div class="log-line" data-container="${colorIndex}">`;
if (showTimestamps) {
html += `<span class="log-timestamp">${timestamp}</span>`;
}
if (selectedContainers.length > 1) {
html += `<span class="log-container-name">${escapeHtml(log.containerName)}</span>`;
}
html += `<span class="log-text">${escapeHtml(log.log)}</span>`;
html += `</div>`;
});
const shouldScroll = isScrolledToBottom(container);
container.innerHTML = html;
if (shouldScroll && !logsPaused) {
scrollToBottom(container);
}
}
// Helper functions
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function isScrolledToBottom(element) {
return element.scrollHeight - element.scrollTop <= element.clientHeight + 100;
}
function scrollToBottom(element) {
element.scrollTop = element.scrollHeight;
}
function showLogsPlaceholder() {
const container = document.getElementById('logsContainer');
if (container) {
container.innerHTML = `
<div class="logs-placeholder">
<span data-lucide="file-text" style="width: 48px; height: 48px; opacity: 0.3;"></span>
<p>Select one or more containers to view logs</p>
</div>
`;
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
}
// Polling management
function startLogsPolling() {
stopLogsPolling();
const autoRefresh = document.getElementById('logsAutoRefresh')?.checked;
if (selectedContainers.length > 0 && autoRefresh && !logsPaused) {
logger.debug('[LOGS] Starting polling interval (2s)');
logsPollingInterval = setInterval(fetchLogs, 2000); // Poll every 2 seconds (same as container modal)
}
}
function stopLogsPolling() {
if (logsPollingInterval) {
logger.debug('[LOGS] Stopping polling interval');
clearInterval(logsPollingInterval);
logsPollingInterval = null;
}
}
// Control functions
function toggleLogsAutoRefresh() {
const autoRefresh = document.getElementById('logsAutoRefresh').checked;
if (autoRefresh) {
startLogsPolling();
} else {
stopLogsPolling();
}
}
function toggleLogsSort() {
logsSortOrder = logsSortOrder === 'asc' ? 'desc' : 'asc';
saveLogsSortOrder();
updateLogsSortButton();
// Re-sort and re-render existing logs
allLogs.sort((a, b) => {
const aTime = new Date(a.timestamp);
const bTime = new Date(b.timestamp);
return logsSortOrder === 'asc' ? aTime - bTime : bTime - aTime;
});
renderLogs();
}
function updateLogsSortButton() {
const btn = document.getElementById('logsSortBtn');
if (!btn) return;
if (logsSortOrder === 'desc') {
btn.innerHTML = '<span data-lucide="arrow-down-wide-narrow"></span> Newest First';
} else {
btn.innerHTML = '<span data-lucide="arrow-up-narrow-wide"></span> Oldest First';
}
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
function clearLogs() {
allLogs = [];
renderLogs();
}
function downloadLogs() {
if (allLogs.length === 0) return;
const showTimestamps = document.getElementById('logsTimestamps').checked;
let content = '';
allLogs.forEach(log => {
let line = '';
if (showTimestamps) {
const timestamp = new Date(log.timestamp).toISOString();
line += `[${timestamp}] `;
}
if (selectedContainers.length > 1) {
line += `[${log.containerName}] `;
}
line += log.log + '\n';
content += line;
});
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `container-logs-${new Date().toISOString()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function filterLogs() {
renderLogs();
}
function toggleTimestamps() {
renderLogs();
}
// Hook into page switching
document.addEventListener('DOMContentLoaded', function() {
const originalSwitchPage = window.switchPage;
if (originalSwitchPage) {
window.switchPage = function(page) {
originalSwitchPage(page);
if (page === 'logs') {
initLogsPage();
} else {
stopLogsPolling();
}
};
}
});

381
dockmon/src/js/metrics.js Normal file
View File

@@ -0,0 +1,381 @@
// Host Metrics Management
// Handles visualization of host metrics received via WebSocket
// Configuration constants
const HOST_CHART_DATA_POINTS = 60; // ~5 minutes of data at 5s intervals
const CONTAINER_CHART_DATA_POINTS = 20; // ~1.5 minutes of data at 5s intervals
const CHART_LOAD_TIMEOUT = 5000; // Timeout for Chart.js to load (ms)
const CHART_POLL_INTERVAL = 50; // Polling interval for Chart.js availability (ms)
const METRICS_CLEANUP_INTERVAL = 60000; // Cleanup stale metrics every 60 seconds
// Store chart instances for each host
const hostMetricsCharts = new Map(); // host_id -> { cpu: Chart, ram: Chart, net: Chart }
// Store previous network values for rate calculation
const networkHistory = new Map(); // host_id -> { lastRx: 0, lastTx: 0, lastTimestamp: 0 }
// Track pending updates for hosts whose charts aren't ready yet
const pendingHostUpdates = new Map(); // host_id -> metrics
/**
* Create sparkline charts for a host widget
* Called after the host widget DOM is rendered
*/
function createHostMetricsCharts(hostId) {
// Wait for Chart.js to be loaded
const waitForChart = setInterval(() => {
if (typeof Chart === 'undefined') {
return; // Chart.js not loaded yet
}
clearInterval(waitForChart);
const cpuCanvas = document.getElementById(`cpu-chart-${hostId}`);
const ramCanvas = document.getElementById(`ram-chart-${hostId}`);
const netCanvas = document.getElementById(`net-chart-${hostId}`);
if (!cpuCanvas || !ramCanvas || !netCanvas) {
logger.warn(`Charts not found for host ${hostId}`);
return;
}
// Destroy existing charts if they exist
const existing = hostMetricsCharts.get(hostId);
if (existing) {
existing.cpu?.destroy();
existing.ram?.destroy();
existing.net?.destroy();
}
// Create charts with different colors
const cpuChart = createSparkline(cpuCanvas, '#3b82f6', HOST_CHART_DATA_POINTS); // blue
const ramChart = createSparkline(ramCanvas, '#10b981', HOST_CHART_DATA_POINTS); // green
const netChart = createSparkline(netCanvas, '#8b5cf6', HOST_CHART_DATA_POINTS); // purple
hostMetricsCharts.set(hostId, {
cpu: cpuChart,
ram: ramChart,
net: netChart
});
// Initialize network history
networkHistory.set(hostId, {
lastRx: 0,
lastTx: 0,
lastTimestamp: 0
});
logger.debug(`Created metrics charts for host ${hostId}`);
// Process any pending updates
const pending = pendingHostUpdates.get(hostId);
if (pending) {
updateHostMetrics(hostId, pending);
pendingHostUpdates.delete(hostId);
}
}, CHART_POLL_INTERVAL);
// Timeout after configured duration
setTimeout(() => clearInterval(waitForChart), CHART_LOAD_TIMEOUT);
}
/**
* Update host metrics charts from WebSocket data
* @param {string} hostId - Host ID
* @param {object} metrics - Metrics data from WebSocket
*/
function updateHostMetrics(hostId, metrics) {
const charts = hostMetricsCharts.get(hostId);
if (!charts) {
// Charts not created yet - store this update to apply when ready
pendingHostUpdates.set(hostId, metrics);
return;
}
// Update CPU chart
if (charts.cpu && metrics.cpu_percent !== undefined) {
updateSparkline(charts.cpu, metrics.cpu_percent);
}
// Update RAM chart
if (charts.ram && metrics.memory_percent !== undefined) {
updateSparkline(charts.ram, metrics.memory_percent);
}
// Calculate network rate (bytes/sec)
const history = networkHistory.get(hostId);
if (history && metrics.network_rx_bytes !== undefined && metrics.network_tx_bytes !== undefined) {
const now = Date.now();
if (history.lastTimestamp > 0) {
const timeDelta = (now - history.lastTimestamp) / 1000; // seconds
if (timeDelta > 0) {
const rxDelta = metrics.network_rx_bytes - history.lastRx;
const txDelta = metrics.network_tx_bytes - history.lastTx;
const totalRate = (rxDelta + txDelta) / timeDelta; // bytes/sec
// Convert to percentage (scale to 100, assuming 1 Gbps = 125 MB/s as max)
const netPercent = Math.min((totalRate / (125 * 1024 * 1024)) * 100, 100);
if (charts.net) {
updateSparkline(charts.net, netPercent);
}
// Update network value display
const netValue = document.getElementById(`net-value-${hostId}`);
if (netValue) {
netValue.textContent = formatBytes(totalRate) + '/s';
}
}
}
// Update history
history.lastRx = metrics.network_rx_bytes;
history.lastTx = metrics.network_tx_bytes;
history.lastTimestamp = now;
}
// Update text values
const cpuValue = document.getElementById(`cpu-value-${hostId}`);
const ramValue = document.getElementById(`ram-value-${hostId}`);
if (cpuValue && metrics.cpu_percent !== undefined) {
cpuValue.textContent = `${metrics.cpu_percent}%`;
}
if (ramValue && metrics.memory_percent !== undefined) {
ramValue.textContent = `${metrics.memory_percent}%`;
}
}
/**
* Cleanup metrics for a removed host
*/
function removeHostMetrics(hostId) {
const charts = hostMetricsCharts.get(hostId);
if (charts) {
// Destroy charts
if (charts.cpu) charts.cpu.destroy();
if (charts.ram) charts.ram.destroy();
if (charts.net) charts.net.destroy();
hostMetricsCharts.delete(hostId);
}
networkHistory.delete(hostId);
pendingHostUpdates.delete(hostId);
}
// Container Sparklines Management
const containerSparklineCharts = new Map(); // "hostId-containerId" -> { cpu: Chart, ram: Chart, net: Chart }
const containerNetworkHistory = new Map(); // Track previous network values for rate calculation
const containerChartsReady = new Map(); // Track Promise resolvers for when charts are ready
const pendingUpdates = new Map(); // Queue updates that arrive before charts are ready
/**
* Remove container metrics and cleanup resources
*/
function removeContainerMetrics(hostId, containerId) {
const key = `${hostId}-${containerId}`;
const charts = containerSparklineCharts.get(key);
if (charts) {
// Destroy charts
if (charts.cpu) charts.cpu.destroy();
if (charts.ram) charts.ram.destroy();
if (charts.net) charts.net.destroy();
containerSparklineCharts.delete(key);
}
// Remove network history
containerNetworkHistory.delete(key);
// Remove ready promise
containerChartsReady.delete(key);
// Remove pending updates
pendingUpdates.delete(key);
}
// Export for use in other modules
window.removeContainerMetrics = removeContainerMetrics;
/**
* Create sparkline charts for a container
* Returns a Promise that resolves when charts are created
*/
function createContainerSparklines(hostId, containerId) {
const key = `${hostId}-${containerId}`;
// Create a Promise that will resolve when charts are ready
const readyPromise = new Promise((resolve) => {
// Wait for Chart.js to be loaded
const waitForChart = setInterval(() => {
if (typeof Chart === 'undefined') {
return; // Chart.js not loaded yet
}
clearInterval(waitForChart);
const cpuCanvas = document.getElementById(`container-cpu-${key}`);
const ramCanvas = document.getElementById(`container-ram-${key}`);
const netCanvas = document.getElementById(`container-net-${key}`);
if (!cpuCanvas || !ramCanvas || !netCanvas) {
resolve(false); // Elements not found
return;
}
// Destroy existing charts if they exist
const existing = containerSparklineCharts.get(key);
if (existing) {
existing.cpu?.destroy();
existing.ram?.destroy();
existing.net?.destroy();
}
// Create mini sparklines
const cpuChart = createSparkline(cpuCanvas, '#3b82f6', CONTAINER_CHART_DATA_POINTS); // blue
const ramChart = createSparkline(ramCanvas, '#10b981', CONTAINER_CHART_DATA_POINTS); // green
const netChart = createSparkline(netCanvas, '#a855f7', CONTAINER_CHART_DATA_POINTS); // purple
containerSparklineCharts.set(key, {
cpu: cpuChart,
ram: ramChart,
net: netChart
});
resolve(true); // Charts created successfully
// Process any pending updates
const pending = pendingUpdates.get(key);
if (pending) {
updateContainerSparklines(hostId, containerId, pending);
pendingUpdates.delete(key);
}
}, CHART_POLL_INTERVAL);
// Timeout after configured duration
setTimeout(() => {
clearInterval(waitForChart);
resolve(false);
}, CHART_LOAD_TIMEOUT);
});
containerChartsReady.set(key, readyPromise);
return readyPromise;
}
/**
* Update container sparklines from container data
* If charts aren't ready yet, queue the update
*/
function updateContainerSparklines(hostId, containerId, containerData) {
const key = `${hostId}-${containerId}`;
const charts = containerSparklineCharts.get(key);
if (!charts) {
// Charts not created yet - store this update to apply when ready
pendingUpdates.set(key, containerData);
return;
}
// Update CPU sparkline
if (charts.cpu && containerData.cpu_percent !== undefined && containerData.cpu_percent !== null) {
updateSparkline(charts.cpu, containerData.cpu_percent);
// Update CPU text value
const cpuValueEl = document.querySelector(`#container-cpu-${key}`).closest('.container-stats').querySelector('.container-stats-values > div:nth-child(1)');
if (cpuValueEl) {
cpuValueEl.textContent = `CPU ${containerData.cpu_percent.toFixed(1)}%`;
}
}
// Update RAM sparkline (convert bytes to percentage for visualization)
if (charts.ram && containerData.memory_usage !== undefined && containerData.memory_usage !== null && containerData.memory_limit !== undefined && containerData.memory_limit > 0) {
const ramPercent = (containerData.memory_usage / containerData.memory_limit) * 100;
updateSparkline(charts.ram, ramPercent);
// Update RAM text value
const ramValueEl = document.querySelector(`#container-ram-${key}`).closest('.container-stats').querySelector('.container-stats-values > div:nth-child(2)');
if (ramValueEl) {
ramValueEl.textContent = `RAM ${formatBytes(containerData.memory_usage)}`;
}
}
// Update Network sparkline (calculate rate from cumulative values)
if (charts.net && containerData.network_tx !== undefined && containerData.network_rx !== undefined) {
const now = Date.now();
const history = containerNetworkHistory.get(key);
if (history) {
const timeDelta = (now - history.timestamp) / 1000; // seconds
if (timeDelta >= 1) {
const txRate = (containerData.network_tx - history.tx) / timeDelta;
const rxRate = (containerData.network_rx - history.rx) / timeDelta;
const totalRate = Math.max(0, txRate + rxRate);
updateSparkline(charts.net, totalRate / 1024); // Convert to KB for better visualization
// Update text value
const valueEl = document.getElementById(`container-net-value-${key}`);
if (valueEl) {
valueEl.textContent = `NET ${formatBytes(totalRate)}/s`;
}
// Update history
containerNetworkHistory.set(key, {
tx: containerData.network_tx,
rx: containerData.network_rx,
timestamp: now
});
}
} else {
// Initialize history
containerNetworkHistory.set(key, {
tx: containerData.network_tx,
rx: containerData.network_rx,
timestamp: now
});
}
}
}
/**
* Cleanup stale container metrics
* Call this periodically to remove metrics for containers that no longer exist
*/
function cleanupStaleContainerMetrics() {
const currentContainers = new Set();
// Collect all currently visible containers
document.querySelectorAll('.container-item').forEach(item => {
const cpuCanvas = item.querySelector('[id^="container-cpu-"]');
if (cpuCanvas) {
const key = cpuCanvas.id.replace('container-cpu-', '');
currentContainers.add(key);
}
});
// Remove metrics for containers that no longer exist
for (const key of containerSparklineCharts.keys()) {
if (!currentContainers.has(key)) {
const [hostId, containerId] = key.split('-');
removeContainerMetrics(hostId, containerId);
}
}
// Also cleanup network history for containers that no longer exist
for (const key of containerNetworkHistory.keys()) {
if (!currentContainers.has(key)) {
containerNetworkHistory.delete(key);
}
}
}
// Run cleanup periodically
const metricsCleanupInterval = setInterval(cleanupStaleContainerMetrics, METRICS_CLEANUP_INTERVAL);
// Stop cleanup when page is unloaded
window.addEventListener('beforeunload', () => {
clearInterval(metricsCleanupInterval);
});

View File

@@ -0,0 +1,728 @@
async function populateNotificationChannels() {
try {
const response = await fetch(`${API_BASE}/api/notifications/channels`, {
credentials: 'include'
});
const channels = await response.json();
const channelsSection = document.getElementById('notificationChannelsSection');
if (channels && channels.length > 0) {
// Show available channels as checkboxes
let channelsHtml = '<div class="checkbox-group">';
channels.forEach(channel => {
channelsHtml += `
<label class="checkbox-item">
<input type="checkbox" name="channels" value="${channel.id}" data-channel-id="${channel.id}">
${channel.name}
</label>
`;
});
channelsHtml += '</div>';
channelsSection.innerHTML = channelsHtml;
} else {
// No channels configured yet
channelsSection.innerHTML = `
<p style="color: var(--text-tertiary); font-size: 14px; margin-bottom: 15px;">
No notification channels configured yet. Set up Discord, Telegram, Pushover, Gotify, SMTP, or Slack to receive alerts.
</p>
<button type="button" class="btn btn-secondary" onclick="openNotificationSettings()">
Configure Channels
</button>
`;
}
} catch (error) {
logger.error('Error fetching notification channels:', error);
const channelsSection = document.getElementById('notificationChannelsSection');
channelsSection.innerHTML = `
<p style="color: var(--text-tertiary); font-size: 14px; margin-bottom: 15px;">
Configure notification channels first in Settings to enable alerts.
</p>
<button type="button" class="btn btn-secondary" onclick="openNotificationSettings()">
Configure Channels
</button>
`;
}
}
let notificationChannels = [];
let templateVariables = {};
async function openNotificationSettings() {
try {
await loadNotificationChannels();
await loadNotificationTemplate();
await loadTemplateVariables();
await fetchSettings(); // Load settings including blackout windows
document.getElementById('notificationModal').classList.add('active');
} catch (error) {
logger.error('Error opening notification settings:', error);
showToast('Failed to open notification settings', 'error');
}
}
function switchNotificationTab(tab) {
const channelsTab = document.getElementById('channelsTab');
const templateTab = document.getElementById('templateTab');
const blackoutTab = document.getElementById('blackoutTab');
const channelsContent = document.getElementById('channelsTabContent');
const templateContent = document.getElementById('templateTabContent');
const blackoutContent = document.getElementById('blackoutTabContent');
// Reset all tabs
[channelsTab, templateTab, blackoutTab].forEach(btn => {
btn.classList.remove('active');
btn.style.borderBottom = 'none';
btn.style.color = 'var(--text-secondary)';
});
// Hide all content
[channelsContent, templateContent, blackoutContent].forEach(content => {
if (content) content.style.display = 'none';
});
// Show selected tab
if (tab === 'channels') {
channelsTab.classList.add('active');
channelsTab.style.borderBottom = '2px solid var(--primary)';
channelsTab.style.color = 'var(--primary)';
channelsContent.style.display = 'block';
} else if (tab === 'template') {
templateTab.classList.add('active');
templateTab.style.borderBottom = '2px solid var(--primary)';
templateTab.style.color = 'var(--primary)';
templateContent.style.display = 'block';
} else if (tab === 'blackout') {
blackoutTab.classList.add('active');
blackoutTab.style.borderBottom = '2px solid var(--primary)';
blackoutTab.style.color = 'var(--primary)';
blackoutContent.style.display = 'block';
// Load blackout status and show timezone info when switching to this tab
renderBlackoutWindows();
updateBlackoutStatus();
updateTimezoneInfo();
}
}
function updateTimezoneInfo() {
const offsetElement = document.getElementById('localTimezoneOffset');
if (offsetElement) {
const offsetMinutes = new Date().getTimezoneOffset();
const offsetHours = Math.abs(offsetMinutes / 60);
const offsetSign = offsetMinutes > 0 ? '-' : '+';
offsetElement.textContent = `UTC${offsetSign}${offsetHours}`;
}
}
async function loadNotificationChannels() {
try {
const response = await fetch(`${API_BASE}/api/notifications/channels`, {
credentials: 'include'
});
notificationChannels = await response.json();
renderNotificationChannels();
} catch (error) {
logger.error('Error loading notification channels:', error);
notificationChannels = [];
}
}
async function loadNotificationTemplate() {
try {
const response = await fetch(`${API_BASE}/api/settings`, {
credentials: 'include'
});
const settings = await response.json();
const template = settings.alert_template || '';
document.getElementById('alertTemplate').value = template;
} catch (error) {
logger.error('Error loading template:', error);
}
}
async function loadTemplateVariables() {
try {
const response = await fetch(`${API_BASE}/api/notifications/template-variables`, {
credentials: 'include'
});
templateVariables = await response.json();
} catch (error) {
logger.error('Error loading template variables:', error);
}
}
function renderNotificationChannels() {
const container = document.getElementById('notificationChannelsList');
if (!container) return;
if (notificationChannels.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: var(--spacing-lg); color: var(--text-secondary);">
No notification channels configured yet. Click "Add Channel" to get started.
</div>
`;
return;
}
container.innerHTML = notificationChannels.map((channel, index) => `
<div class="notification-channel-card" style="padding: var(--spacing-md); margin-bottom: var(--spacing-md); background: var(--surface); border: 1px solid var(--surface-light); border-radius: var(--radius-md);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-md);">
<h4 style="color: var(--text-primary); margin: 0;">${channel.name || 'Unnamed Channel'}</h4>
<div style="display: flex; gap: var(--spacing-sm);">
<button type="button" class="btn btn-small ${channel.enabled ? 'btn-success' : 'btn-secondary'}" onclick="toggleChannelStatus(${index})">
${channel.enabled ? '✓ Enabled' : 'Disabled'}
</button>
<button type="button" class="btn-icon" onclick="removeChannel(${index})">
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
<div style="display: grid; gap: var(--spacing-sm);">
<label class="form-label">Channel Type</label>
${channel.id ?
`<input type="text" class="form-input" value="${channel.type.charAt(0).toUpperCase() + channel.type.slice(1)}" disabled style="background: var(--surface-light); cursor: not-allowed;">`
:
`<select class="form-input" onchange="updateChannelType(${index}, this.value)">
${getAvailableChannelTypes(channel.type).map(type => `<option value="${type}" ${channel.type === type ? 'selected' : ''}>${type.charAt(0).toUpperCase() + type.slice(1)}</option>`).join('')}
</select>`
}
${renderChannelConfig(channel, index)}
</div>
<div style="display: flex; gap: var(--spacing-sm); margin-top: var(--spacing-md);">
<button type="button" class="btn btn-primary btn-small" onclick="saveChannel(${index})">
<i data-lucide="save"></i> Save Channel
</button>
<button type="button" class="btn btn-secondary btn-small" onclick="testChannel(${index})">
<i data-lucide="bell"></i> Test Channel
</button>
</div>
</div>
`).join('');
initIcons();
}
function renderChannelConfig(channel, index) {
switch(channel.type) {
case 'discord':
return `
<label class="form-label">Webhook URL</label>
<input type="text" class="form-input" placeholder="https://discord.com/api/webhooks/..."
value="${channel.config?.webhook_url || ''}"
onchange="updateChannelConfig(${index}, 'webhook_url', this.value)">
`;
case 'slack':
return `
<label class="form-label">Webhook URL</label>
<input type="text" class="form-input" placeholder="https://hooks.slack.com/services/..."
value="${channel.config?.webhook_url || ''}"
onchange="updateChannelConfig(${index}, 'webhook_url', this.value)">
`;
case 'telegram':
return `
<label class="form-label">Bot Token</label>
<input type="text" class="form-input" placeholder="Bot token from @BotFather"
value="${channel.config?.bot_token || ''}"
onchange="updateChannelConfig(${index}, 'bot_token', this.value)">
<label class="form-label">Chat ID</label>
<input type="text" class="form-input" placeholder="Chat ID"
value="${channel.config?.chat_id || ''}"
onchange="updateChannelConfig(${index}, 'chat_id', this.value)">
`;
case 'pushover':
return `
<label class="form-label">App Token</label>
<input type="text" class="form-input" placeholder="Pushover app token"
value="${channel.config?.app_token || ''}"
onchange="updateChannelConfig(${index}, 'app_token', this.value)">
<label class="form-label">User Key</label>
<input type="text" class="form-input" placeholder="User key"
value="${channel.config?.user_key || ''}"
onchange="updateChannelConfig(${index}, 'user_key', this.value)">
`;
case 'gotify':
return `
<label class="form-label">Server URL</label>
<input type="url" class="form-input" placeholder="http://gotify.example.com"
value="${channel.config?.server_url || ''}"
onchange="updateChannelConfig(${index}, 'server_url', this.value)">
<small style="color: var(--text-tertiary); display: block; margin-top: 4px; margin-bottom: 12px;">
Your Gotify server URL (e.g., http://192.168.1.100:8080)
</small>
<label class="form-label">App Token</label>
<input type="text" class="form-input" placeholder="AaBbCcDd123456"
value="${channel.config?.app_token || ''}"
onchange="updateChannelConfig(${index}, 'app_token', this.value)">
<small style="color: var(--text-tertiary); display: block; margin-top: 4px;">
Create an app in Gotify and copy its token
</small>
`;
case 'smtp':
return `
<label class="form-label">SMTP Server</label>
<input type="text" class="form-input" placeholder="smtp.gmail.com"
value="${channel.config?.smtp_host || ''}"
onchange="updateChannelConfig(${index}, 'smtp_host', this.value)">
<label class="form-label">SMTP Port</label>
<input type="number" class="form-input" placeholder="587"
value="${channel.config?.smtp_port || ''}"
onchange="updateChannelConfig(${index}, 'smtp_port', this.value)">
<label class="form-label">Username</label>
<input type="text" class="form-input" placeholder="user@example.com"
value="${channel.config?.smtp_user || ''}"
onchange="updateChannelConfig(${index}, 'smtp_user', this.value)">
<label class="form-label">Password</label>
<input type="password" class="form-input" placeholder="••••••••"
value="${channel.config?.smtp_password || ''}"
onchange="updateChannelConfig(${index}, 'smtp_password', this.value)">
<label class="form-label">From Email</label>
<input type="email" class="form-input" placeholder="dockmon@example.com"
value="${channel.config?.from_email || ''}"
onchange="updateChannelConfig(${index}, 'from_email', this.value)">
<label class="form-label">To Email</label>
<input type="email" class="form-input" placeholder="alerts@example.com"
value="${channel.config?.to_email || ''}"
onchange="updateChannelConfig(${index}, 'to_email', this.value)">
<label class="form-label" style="display: flex; align-items: center; margin-top: 12px;">
<input type="checkbox" style="margin-right: 8px;"
${channel.config?.use_tls !== false ? 'checked' : ''}
onchange="updateChannelConfig(${index}, 'use_tls', this.checked)">
Use TLS/STARTTLS
</label>
<small style="color: var(--text-tertiary); display: block; margin-top: 4px;">
Enable for ports 587 (STARTTLS) or 465 (SSL/TLS). Disable for port 25.
</small>
`;
default:
return '';
}
}
function addNotificationChannel() {
// Get available channel types (those not already in use)
const usedTypes = notificationChannels.map(ch => ch.type);
const availableTypes = ['discord', 'slack', 'telegram', 'pushover', 'gotify', 'smtp'].filter(type => !usedTypes.includes(type));
if (availableTypes.length === 0) {
showToast('❌ All notification channel types are already configured');
return;
}
// Use the first available type
const channelType = availableTypes[0];
const channelName = channelType.charAt(0).toUpperCase() + channelType.slice(1);
const newChannel = {
name: channelName,
type: channelType,
config: {},
enabled: true,
isNew: true
};
notificationChannels.push(newChannel);
renderNotificationChannels();
// Scroll to the newly added channel
setTimeout(() => {
const cards = document.querySelectorAll('.notification-channel-card');
if (cards.length > 0) {
const lastCard = cards[cards.length - 1];
lastCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 100);
}
async function removeChannel(index) {
const channel = notificationChannels[index];
// If channel doesn't exist in backend, just remove from local array
if (!channel.id) {
notificationChannels.splice(index, 1);
renderNotificationChannels();
return;
}
// Check for dependent alerts first
try {
const dependentAlerts = await checkDependentAlerts(channel.id);
let message = `Are you sure you want to delete the notification channel <strong>"${channel.name}"</strong>?<br><br>`;
message += `Any alerts that only use this notification channel will also be deleted.<br><br>`;
if (dependentAlerts.length > 0) {
message += `⚠️ <strong>Warning:</strong> The following alert rules will be deleted:<br>`;
message += '<ul style="margin: var(--spacing-sm) 0; padding-left: var(--spacing-lg);">';
dependentAlerts.forEach(alert => {
message += `<li>${alert.name}</li>`;
});
message += '</ul>';
}
message += `<strong>This action cannot be undone.</strong>`;
showConfirmation('Delete Notification Channel', message, 'Delete Channel', async () => {
try {
// Delete from backend
const response = await fetch(`${API_BASE}/api/notifications/channels/${channel.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to delete channel');
}
const result = await response.json();
if (result.deleted_alerts && result.deleted_alerts.length > 0) {
showToast(`✅ Channel deleted (${result.deleted_alerts.length} alert(s) also removed)`);
} else {
showToast('✅ Channel deleted');
}
// Remove from local array and re-render
notificationChannels.splice(index, 1);
renderNotificationChannels();
// Refresh alert modal if open
await populateNotificationChannels();
// Refresh alert rules list if it's currently displayed
await fetchAlertRules();
renderAlertRules();
} catch (error) {
logger.error('Error deleting channel:', error);
showToast('❌ Failed to delete channel');
}
});
} catch (error) {
logger.error('Error checking dependent alerts:', error);
showToast('❌ Failed to check dependent alerts');
}
}
function toggleChannelStatus(index) {
notificationChannels[index].enabled = !notificationChannels[index].enabled;
renderNotificationChannels();
}
function getAvailableChannelTypes(currentType) {
// Get all types that are either the current type or not already in use
const usedTypes = notificationChannels.map(ch => ch.type);
const allTypes = ['discord', 'slack', 'telegram', 'pushover', 'gotify', 'smtp'];
return allTypes.filter(type => type === currentType || !usedTypes.includes(type));
}
function updateChannelType(index, type) {
// Update the channel name to match the type
notificationChannels[index].name = type.charAt(0).toUpperCase() + type.slice(1);
notificationChannels[index].type = type;
notificationChannels[index].config = {};
renderNotificationChannels();
}
function updateChannelConfig(index, key, value) {
if (!notificationChannels[index].config) {
notificationChannels[index].config = {};
}
notificationChannels[index].config[key] = value;
}
async function saveAllChannels() {
try {
// Save each channel
for (const channel of notificationChannels) {
if (channel.isNew) {
// Create new channel
await fetch(`${API_BASE}/api/notifications/channels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(channel)
});
} else if (channel.id) {
// Update existing channel
await fetch(`${API_BASE}/api/notifications/channels/${channel.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(channel)
});
}
}
showToast('✅ Notification channels saved successfully!');
await loadNotificationChannels();
// Refresh the alert modal's channel list if it's open
await populateNotificationChannels();
} catch (error) {
logger.error('Error saving channels:', error);
showToast('❌ Failed to save notification channels');
}
}
function validateChannelConfig(channel) {
// Validate based on channel type
const config = channel.config || {};
switch (channel.type) {
case 'discord':
case 'slack':
if (!config.webhook_url || !config.webhook_url.trim()) {
return { valid: false, error: 'Webhook URL is required' };
}
if (!config.webhook_url.startsWith('https://')) {
return { valid: false, error: 'Webhook URL must start with https://' };
}
break;
case 'telegram':
if (!config.bot_token || !config.bot_token.trim()) {
return { valid: false, error: 'Bot token is required' };
}
if (!config.chat_id || !config.chat_id.trim()) {
return { valid: false, error: 'Chat ID is required' };
}
break;
case 'pushover':
if (!config.app_token || !config.app_token.trim()) {
return { valid: false, error: 'App token is required' };
}
if (!config.user_key || !config.user_key.trim()) {
return { valid: false, error: 'User key is required' };
}
break;
case 'gotify':
if (!config.server_url || !config.server_url.trim()) {
return { valid: false, error: 'Gotify server URL is required' };
}
if (!config.server_url.startsWith('http://') && !config.server_url.startsWith('https://')) {
return { valid: false, error: 'Gotify server URL must start with http:// or https://' };
}
if (!config.app_token || !config.app_token.trim()) {
return { valid: false, error: 'Gotify app token is required' };
}
break;
case 'smtp':
if (!config.smtp_host || !config.smtp_host.trim()) {
return { valid: false, error: 'SMTP server is required' };
}
const port = parseInt(config.smtp_port);
if (isNaN(port) || port < 1 || port > 65535) {
return { valid: false, error: 'SMTP port must be between 1-65535' };
}
if (!config.smtp_user || !config.smtp_user.trim()) {
return { valid: false, error: 'SMTP username is required' };
}
if (!config.smtp_password || !config.smtp_password.trim()) {
return { valid: false, error: 'SMTP password is required' };
}
if (!config.from_email || !config.from_email.trim()) {
return { valid: false, error: 'From email is required' };
}
if (!config.to_email || !config.to_email.trim()) {
return { valid: false, error: 'To email is required' };
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(config.from_email)) {
return { valid: false, error: 'Invalid from email format' };
}
if (!emailRegex.test(config.to_email)) {
return { valid: false, error: 'Invalid to email format' };
}
break;
}
return { valid: true };
}
async function saveChannel(index) {
try {
const channel = notificationChannels[index];
// Validate channel configuration before saving
const validation = validateChannelConfig(channel);
if (!validation.valid) {
showToast(`${validation.error}`);
return;
}
if (channel.isNew) {
// Create new channel
const response = await fetch(`${API_BASE}/api/notifications/channels`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(channel)
});
if (response.ok) {
const savedChannel = await response.json();
// Update the channel with the returned ID and remove isNew flag
notificationChannels[index] = { ...savedChannel, isNew: false };
showToast('✅ Channel saved successfully!');
} else {
const errorData = await response.json().catch(() => ({}));
let errorMsg = 'Failed to save channel';
if (errorData.detail) {
if (Array.isArray(errorData.detail)) {
// Pydantic validation errors are arrays
errorMsg = errorData.detail.map(err => err.msg || JSON.stringify(err)).join(', ');
} else if (typeof errorData.detail === 'string') {
errorMsg = errorData.detail;
} else {
errorMsg = JSON.stringify(errorData.detail);
}
}
throw new Error(errorMsg);
}
} else if (channel.id) {
// Update existing channel
const response = await fetch(`${API_BASE}/api/notifications/channels/${channel.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(channel)
});
if (response.ok) {
showToast('✅ Channel updated successfully!');
} else {
throw new Error('Failed to update channel');
}
}
// Preserve unsaved channels (those with isNew flag)
const unsavedChannels = notificationChannels.filter(ch => ch.isNew && ch !== channel);
// Refresh the channels list from backend
await loadNotificationChannels();
// Re-add the unsaved channels
notificationChannels.push(...unsavedChannels);
renderNotificationChannels();
// Refresh the alert modal's channel list if it's open
await populateNotificationChannels();
} catch (error) {
logger.error('Error saving channel:', error);
showToast(`${error.message || 'Failed to save channel'}`);
}
}
async function testChannel(index) {
const channel = notificationChannels[index];
if (!channel.id) {
showToast('❌ Please save the channel first');
return;
}
try {
const response = await fetch(`${API_BASE}/api/notifications/channels/${channel.id}/test`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
showToast('✅ Test notification sent!');
} else {
showToast('❌ Failed to send test notification');
}
} catch (error) {
logger.error('Error testing channel:', error);
showToast('❌ Failed to test channel');
}
}
function applyTemplateExample(exampleKey) {
if (!exampleKey || !templateVariables.examples) return;
const examples = {
'default': templateVariables.default_template,
'simple': 'Alert: {CONTAINER_NAME} on {HOST_NAME} changed from {OLD_STATE} to {NEW_STATE}',
'minimal': '{CONTAINER_NAME}: {NEW_STATE} at {TIME}',
'emoji': '🔴 {CONTAINER_NAME} is {NEW_STATE}\\n📍 Host: {HOST_NAME}\\n🕐 Time: {TIMESTAMP}'
};
const template = examples[exampleKey];
if (template) {
document.getElementById('alertTemplate').value = template;
}
}
async function saveNotificationTemplate() {
try {
const template = document.getElementById('alertTemplate').value;
// Get current settings
const response = await fetch(`${API_BASE}/api/settings`, {
credentials: 'include'
});
const settings = await response.json();
// Update with new template
settings.alert_template = template;
const updateResponse = await fetch(`${API_BASE}/api/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(settings)
});
if (updateResponse.ok) {
showToast('✅ Notification template saved!');
} else {
showToast('❌ Failed to save template');
}
} catch (error) {
logger.error('Error saving template:', error);
showToast('❌ Failed to save notification template');
}
}
async function loadNotificationSettings() {
try {
const response = await fetch(`${API_BASE}/api/notifications/channels`, {
credentials: 'include'
});
const channels = await response.json();
// Clear existing values
document.querySelectorAll('#notificationModal .form-input').forEach(input => {
input.value = '';
});
// Populate existing settings
channels.forEach(channel => {
if (channel.type === 'telegram') {
const tokenInput = document.querySelector('#notificationModal .form-input[placeholder*="Telegram bot token"]');
const chatInput = document.querySelector('#notificationModal .form-input[placeholder*="chat ID"]');
if (tokenInput && chatInput && channel.config) {
tokenInput.value = channel.config.bot_token || '';
chatInput.value = channel.config.chat_id || '';
}
} else if (channel.type === 'discord') {
const webhookInput = document.querySelector('#notificationModal .form-input[placeholder*="Discord webhook"]');
if (webhookInput && channel.config) {
webhookInput.value = channel.config.webhook_url || '';
}
} else if (channel.type === 'pushover') {
const tokenInput = document.querySelector('#notificationModal .form-input[placeholder*="Pushover app token"]');
const userInput = document.querySelector('#notificationModal .form-input[placeholder*="user key"]');
if (tokenInput && userInput && channel.config) {
tokenInput.value = channel.config.app_token || '';
userInput.value = channel.config.user_key || '';
}
}
});
} catch (error) {
logger.error('Error loading notification settings:', error);
}
}

465
dockmon/src/login.html Normal file
View File

@@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DockMon - Login</title>
<style>
:root {
--primary: #0066cc;
--primary-dark: #0052a3;
--secondary: #6c757d;
--success: #28a745;
--danger: #dc3545;
--warning: #ffc107;
--info: #17a2b8;
--light: #f8f9fa;
--dark: #343a40;
--background: #1a1a1a;
--surface: #2d2d2d;
--surface-light: #3d3d3d;
--text-primary: #ffffff;
--text-secondary: #b3b3b3;
--border: #404040;
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 3rem;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'sans-serif';
background: linear-gradient(135deg, var(--background) 0%, #0f1419 100%);
color: var(--text-primary);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* Animated background particles */
.background-animation {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.particle {
position: absolute;
background: rgba(0, 102, 204, 0.1);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
}
.particle:nth-child(1) { width: 80px; height: 80px; left: 10%; animation-delay: 0s; }
.particle:nth-child(2) { width: 60px; height: 60px; left: 20%; animation-delay: 1s; }
.particle:nth-child(3) { width: 100px; height: 100px; left: 35%; animation-delay: 2s; }
.particle:nth-child(4) { width: 40px; height: 40px; left: 70%; animation-delay: 3s; }
.particle:nth-child(5) { width: 120px; height: 120px; left: 85%; animation-delay: 4s; }
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); opacity: 0.1; }
50% { transform: translateY(-20px) rotate(180deg); opacity: 0.3; }
}
/* Login container */
.login-container {
background: var(--surface);
border-radius: 16px;
padding: var(--spacing-xl);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
border: 1px solid var(--border);
width: 100%;
max-width: 400px;
position: relative;
z-index: 1;
backdrop-filter: blur(10px);
}
.login-header {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.login-logo {
width: 80px;
height: 80px;
margin: 0 auto var(--spacing-lg);
display: flex;
align-items: center;
justify-content: center;
}
.login-logo img {
width: 100%;
height: 100%;
object-fit: contain;
filter: drop-shadow(0 8px 16px rgba(0, 102, 204, 0.3));
}
.login-title {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: var(--spacing-sm);
}
.login-subtitle {
color: var(--text-secondary);
font-size: 16px;
margin-bottom: var(--spacing-lg);
}
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-label {
display: block;
margin-bottom: var(--spacing-sm);
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
}
.form-control {
width: 100%;
padding: var(--spacing-md);
background: var(--background);
border: 2px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 16px;
transition: all 0.3s ease;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.form-control::placeholder {
color: var(--text-secondary);
}
.btn {
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 102, 204, 0.3);
}
.btn:active {
transform: translateY(0);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.alert {
padding: var(--spacing-md);
border-radius: 8px;
margin-bottom: var(--spacing-lg);
font-size: 14px;
border: 1px solid transparent;
}
.alert-danger {
background: rgba(220, 53, 69, 0.1);
border-color: rgba(220, 53, 69, 0.3);
color: #ff6b6b;
}
.alert-success {
background: rgba(40, 167, 69, 0.1);
border-color: rgba(40, 167, 69, 0.3);
color: #51cf66;
}
.loading {
display: none;
text-align: center;
margin-top: var(--spacing-md);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top: 2px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
display: inline-block;
margin-right: var(--spacing-sm);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.forgot-password {
text-align: center;
margin-top: var(--spacing-lg);
color: var(--text-secondary);
font-size: 14px;
}
.version {
position: absolute;
bottom: var(--spacing-md);
left: 50%;
transform: translateX(-50%);
color: var(--text-secondary);
font-size: 12px;
z-index: 1;
}
/* Responsive design */
@media (max-width: 480px) {
.login-container {
margin: var(--spacing-md);
padding: var(--spacing-lg);
}
.login-title {
font-size: 24px;
}
}
/* Focus animations */
.form-group {
position: relative;
}
.form-control:focus + .form-label {
color: var(--primary);
}
</style>
</head>
<body>
<!-- Background animation -->
<div class="background-animation">
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
<div class="particle"></div>
</div>
<!-- Login container -->
<div class="login-container">
<div class="login-header">
<div class="login-logo">
<img src="images/logo.png" alt="DockMon Logo">
</div>
<h1 class="login-title">DockMon</h1>
<p class="login-subtitle">Docker Container Monitoring & Management</p>
</div>
<form id="loginForm">
<div id="alertContainer"></div>
<div class="form-group">
<label class="form-label" for="username">Username</label>
<input type="text" id="username" name="username" class="form-control"
placeholder="Enter your username" required autocomplete="username">
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" id="password" name="password" class="form-control"
placeholder="Enter your password" required autocomplete="current-password">
</div>
<button type="submit" class="btn" id="loginBtn">
Sign In
</button>
<div class="loading" id="loading">
<span class="spinner"></span>
Authenticating...
</div>
</form>
<div class="forgot-password">
<small></small>
</div>
</div>
<div class="version">DockMon v1.1.2</div>
<script>
const API_BASE = '';
// DOM elements
const loginForm = document.getElementById('loginForm');
const loginBtn = document.getElementById('loginBtn');
const loading = document.getElementById('loading');
const alertContainer = document.getElementById('alertContainer');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
// Show alert message
function showAlert(message, type = 'danger') {
alertContainer.innerHTML = `
<div class="alert alert-${type}">
${message}
</div>
`;
}
// Clear alerts
function clearAlert() {
alertContainer.innerHTML = '';
}
// Show loading state
function setLoading(isLoading) {
if (isLoading) {
loginBtn.disabled = true;
loginBtn.style.display = 'none';
loading.style.display = 'block';
} else {
loginBtn.disabled = false;
loginBtn.style.display = 'block';
loading.style.display = 'none';
}
}
// Check if already authenticated
async function checkAuthStatus() {
try {
const response = await fetch(`${API_BASE}/api/auth/status`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.authenticated) {
// Redirect to main dashboard
window.location.href = '/';
return;
}
}
} catch (error) {
console.log('Auth check failed:', error);
}
}
// Handle login form submission
async function handleLogin(event) {
event.preventDefault();
clearAlert();
setLoading(true);
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username || !password) {
showAlert('Please enter both username and password');
setLoading(false);
return;
}
try {
const response = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ username, password })
});
const data = await response.json();
console.log('Login response:', data);
if (response.ok) {
// Store password change requirement in sessionStorage
if (data.must_change_password || data.is_first_login) {
sessionStorage.setItem('must_change_password', 'true');
// Temporarily store password for auto-fill (will be cleared after use)
sessionStorage.setItem('temp_current_password', password);
}
showAlert('Login successful! Redirecting...', 'success');
console.log('Login successful, redirecting to dashboard in 1.5 seconds...');
// Redirect to main dashboard after short delay
setTimeout(() => {
console.log('Redirecting to /');
window.location.href = '/';
}, 1500);
} else {
showAlert(data.detail || 'Login failed. Please try again.');
setLoading(false);
}
} catch (error) {
console.error('Login error:', error);
showAlert('Connection error. Please check if the backend is running.');
setLoading(false);
}
}
// Event listeners
loginForm.addEventListener('submit', handleLogin);
// Auto-focus username field
usernameInput.focus();
// Check auth status on page load
checkAuthStatus();
// Handle Enter key in password field
passwordInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
handleLogin(event);
}
});
// Add some visual feedback for form interactions
[usernameInput, passwordInput].forEach(input => {
input.addEventListener('focus', function() {
this.parentElement.style.transform = 'scale(1.02)';
this.parentElement.style.transition = 'transform 0.2s ease';
});
input.addEventListener('blur', function() {
this.parentElement.style.transform = 'scale(1)';
});
});
</script>
</body>
</html>

12
dockmon/src/lucide.min.js vendored Normal file

File diff suppressed because one or more lines are too long