Switched from Dockmon to Beszel
This commit is contained in:
2494
dockmon/src/css/main.css
Normal file
2494
dockmon/src/css/main.css
Normal file
File diff suppressed because it is too large
Load Diff
30
dockmon/src/css/variables.css
Normal file
30
dockmon/src/css/variables.css
Normal 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
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
BIN
dockmon/src/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
dockmon/src/images/logo_large.png
Normal file
BIN
dockmon/src/images/logo_large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
1015
dockmon/src/index.html
Normal file
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
900
dockmon/src/js/alerts.js
Normal 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
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
640
dockmon/src/js/core.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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
837
dockmon/src/js/dashboard.js
Normal 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
471
dockmon/src/js/events.js
Normal 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
68
dockmon/src/js/logger.js
Normal 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
539
dockmon/src/js/logs.js
Normal 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
381
dockmon/src/js/metrics.js
Normal 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);
|
||||
});
|
||||
728
dockmon/src/js/notifications.js
Normal file
728
dockmon/src/js/notifications.js
Normal 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
465
dockmon/src/login.html
Normal 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
12
dockmon/src/lucide.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user