Files
url_tracker_tool/public/script.js

838 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
document.addEventListener('DOMContentLoaded', () => {
const urlForm = document.getElementById('urlForm');
const urlInput = document.getElementById('urlInput');
const httpMethodSelect = document.getElementById('httpMethod');
const userAgentSelect = document.getElementById('userAgent');
const loading = document.getElementById('loading');
const results = document.getElementById('results');
const redirectList = document.getElementById('redirectList');
const errorDiv = document.getElementById('error');
const errorMessage = document.querySelector('.error-message');
const warningsDiv = document.getElementById('warnings');
const warningContainer = document.querySelector('.warning-container');
const summaryContainer = document.querySelector('.summary-container');
const toggleModeBtn = document.getElementById('toggleMode');
const printResultsBtn = document.getElementById('printResults');
const mermaidGraph = document.getElementById('mermaid-graph');
// Check if all required elements exist
if (!urlForm || !urlInput || !redirectList || !errorMessage || !warningContainer || !summaryContainer) {
console.error('One or more required HTML elements not found. Check your HTML structure.');
return;
}
// Hide the graph view tab
const graphTabButton = document.querySelector('.tab-main[data-tab="graph-view"]');
if (graphTabButton) {
graphTabButton.style.display = 'none';
}
// Set the list view as the only visible tab
const listViewContent = document.getElementById('list-view');
if (listViewContent) {
listViewContent.classList.remove('hidden');
}
// User agent options
const userAgents = {
default: '',
googlebot: 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
bingbot: 'Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)',
chrome: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113 Safari/537.36)',
iphone: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.4 Mobile/15E148 Safari/604.1'
};
// Set up tab switching
const tabButtons = document.querySelectorAll('.tab-main');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
const tabId = button.getAttribute('data-tab');
// Skip processing if it's the graph-view tab
if (tabId === 'graph-view') {
return;
}
// Remove active class from all buttons and content
tabButtons.forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-main-content').forEach(content => {
content.classList.add('hidden');
});
// Add active class to clicked button and show content
button.classList.add('active');
document.getElementById(tabId).classList.remove('hidden');
// Re-render mermaid graph if graph view is selected
if (tabId === 'graph-view') {
console.log("Switched to graph view tab");
renderMermaidGraph();
}
});
});
// Toggle dark mode
if (toggleModeBtn) {
// Function to toggle dark mode
const toggleDarkMode = (isDark) => {
if (isDark) {
document.body.classList.add('dark-mode');
toggleModeBtn.setAttribute('aria-pressed', 'true');
localStorage.setItem('darkMode', 'enabled');
} else {
document.body.classList.remove('dark-mode');
toggleModeBtn.setAttribute('aria-pressed', 'false');
localStorage.setItem('darkMode', 'disabled');
}
};
// Check for system preference
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
// Check if dark mode was previously set
const storedTheme = localStorage.getItem('darkMode');
if (storedTheme === 'disabled') {
// User explicitly disabled dark mode
toggleDarkMode(false);
} else {
// Default to dark mode for all other cases:
// - When no stored preference
// - When stored preference is "enabled"
toggleDarkMode(true);
}
// Initialize ARIA attributes
toggleModeBtn.setAttribute('aria-pressed', document.body.classList.contains('dark-mode').toString());
// Add event listener for toggle button
toggleModeBtn.addEventListener('click', () => {
const isDarkMode = document.body.classList.contains('dark-mode');
toggleDarkMode(!isDarkMode);
});
// Also listen for system changes
prefersDarkScheme.addEventListener('change', (e) => {
if (localStorage.getItem('darkMode') === null) {
// Only auto-switch if user hasn't set a preference
toggleDarkMode(e.matches);
}
});
}
// Print results
printResultsBtn.addEventListener('click', () => {
window.print();
});
urlForm.addEventListener('submit', async (e) => {
e.preventDefault();
const url = urlInput.value.trim();
const method = httpMethodSelect.value;
const userAgent = userAgents[userAgentSelect.value] || '';
if (!url) {
showError('Please enter a valid URL');
return;
}
// Clear previous results
redirectList.innerHTML = '';
hideError();
hideWarnings();
showLoading();
hideResults();
try {
const response = await fetch('/api/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
method,
userAgent
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to track redirects');
}
// Check for potential issues with the redirect chain
checkRedirectIssues(data.redirects);
// Generate and display redirect summary
generateRedirectSummary(data.redirects);
// Generate Mermaid graph
generateMermaidGraph(data.redirects);
// Display the redirects in list format
displayRedirects(data.redirects);
} catch (error) {
showError(error.message);
} finally {
hideLoading();
}
});
function checkRedirectIssues(redirects) {
// Clear previous warnings
warningContainer.innerHTML = '';
let warningCount = 0;
// Loop detection
const urlSet = new Set();
const loopUrl = redirects.find(redirect => {
if (urlSet.has(redirect.url)) {
return true;
}
urlSet.add(redirect.url);
return false;
});
if (loopUrl) {
addWarning(`⚠️ Redirect loop detected! URL "${loopUrl.url}" appears multiple times in the chain.`);
warningCount++;
}
// SSL downgrade detection
for (let i = 1; i < redirects.length; i++) {
const prevUrl = redirects[i-1].url;
const currUrl = redirects[i].url;
if (prevUrl.toLowerCase().startsWith('https://') && currUrl.toLowerCase().startsWith('http://')) {
addWarning(`⚠️ Mixed content warning: SSL downgrade from "${prevUrl}" to "${currUrl}"`);
warningCount++;
}
}
// Show warnings if any
if (warningCount > 0) {
showWarnings();
}
}
function generateRedirectSummary(redirects) {
// Create a summary of status codes
const statusCounts = {};
redirects.forEach(redirect => {
if (redirect.statusCode) {
const statusKey = `${redirect.statusCode} ${redirect.statusText || ''}`;
statusCounts[statusKey] = (statusCounts[statusKey] || 0) + 1;
}
});
// Create summary text
let summaryText = `${redirects.length} step${redirects.length === 1 ? '' : 's'} in chain: `;
const statusSummary = Object.entries(statusCounts)
.map(([status, count]) => `${count}× ${status.trim()}`)
.join(', ');
if (statusSummary) {
summaryText += statusSummary;
} else {
summaryText += 'No redirects';
}
// Add SSL info
const sslCount = redirects.filter(r => r.isSSL).length;
const nonSslCount = redirects.filter(r => !r.isSSL).length;
if (sslCount > 0 || nonSslCount > 0) {
summaryText += ` (${sslCount} SSL, ${nonSslCount} non-SSL)`;
}
summaryContainer.textContent = summaryText;
}
function generateMermaidGraph(redirects) {
console.log('Generating Mermaid graph from redirects:', redirects);
if (!redirects || redirects.length === 0) {
console.log('No redirects to display in graph');
return 'graph TD\n emptyNode["No redirects to display"]\n style emptyNode fill:#f9f9f9,stroke:#ccc,stroke-width:1px';
}
let definition = 'graph TD\n';
// Track visited URLs to avoid duplicates
const visitedUrls = new Set();
// First pass - add all nodes
redirects.forEach((redirect, index) => {
if (!redirect.url || !redirect.redirectUrl) return;
const fromUrl = sanitizeMermaidText(redirect.url);
const toUrl = sanitizeMermaidText(redirect.redirectUrl);
// Skip if we've already processed this exact redirect
const redirectKey = `${fromUrl}|${toUrl}`;
if (visitedUrls.has(redirectKey)) return;
visitedUrls.add(redirectKey);
// Create node labels with truncated URLs
const fromLabel = truncateUrl(redirect.url, 30);
const toLabel = truncateUrl(redirect.redirectUrl, 30);
// Add nodes with ID based on index to avoid duplicate IDs and label issues
definition += ` node${index}["${sanitizeMermaidText(fromLabel)}"]\n`;
definition += ` node${index}to["${sanitizeMermaidText(toLabel)}"]\n`;
// Add connection with status code if available
const statusText = redirect.statusCode ? ` (${redirect.statusCode})` : '';
definition += ` node${index} -->|"${statusText}"| node${index}to\n`;
});
console.log('Generated Mermaid definition:', definition);
return definition;
}
// Separate function to handle Mermaid rendering
function renderMermaidGraph() {
// First make sure Mermaid is properly loaded
if (typeof mermaid === 'undefined') {
console.error('Mermaid library not loaded');
displayGraphError('Graph rendering library not loaded properly.');
return;
}
// Reset and reinitialize Mermaid with current theme
try {
const isDarkMode = document.body.classList.contains('dark-mode');
console.log("Initializing Mermaid with theme:", isDarkMode ? 'dark' : 'default');
mermaid.initialize({
startOnLoad: false,
theme: isDarkMode ? 'dark' : 'default',
securityLevel: 'loose',
logLevel: 'warn', // Change to 'debug' for more detailed logs
fontFamily: 'sans-serif',
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis'
}
});
// Wait a bit to ensure DOM is ready
setTimeout(() => {
try {
console.log("Running mermaid.run()");
mermaid.run({
querySelector: '.mermaid'
}).then(() => {
console.log("Mermaid graph rendered successfully");
}).catch(err => {
console.error("Mermaid rendering error:", err);
displayGraphError('Failed to render graph: ' + err.message);
});
} catch (err) {
console.error('Error during mermaid.run():', err);
// Fallback approach
try {
console.log("Falling back to contentLoaded()");
mermaid.contentLoaded();
} catch (innerErr) {
console.error('Error during fallback rendering:', innerErr);
displayGraphError('Failed to render graph. Technical details: ' + innerErr.message);
}
}
}, 200);
} catch (e) {
console.error('Error initializing Mermaid:', e);
displayGraphError('Failed to initialize graph rendering: ' + e.message);
}
}
// Sanitize text for Mermaid compatibility
function sanitizeMermaidText(text) {
if (!text) return '';
// Escape characters that can break Mermaid syntax
return text
.replace(/"/g, "'") // Replace double quotes with single quotes
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\(/g, '&#40;')
.replace(/\)/g, '&#41;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;')
.replace(/\{/g, '&#123;')
.replace(/\}/g, '&#125;');
}
// Helper function to truncate long URLs for display
function truncateUrl(url, maxLength) {
if (!url) return '';
try {
// Try to parse and display in a more readable format
const urlObj = new URL(url);
const displayUrl = urlObj.hostname + urlObj.pathname;
if (displayUrl.length <= maxLength) {
return displayUrl;
}
return displayUrl.substring(0, maxLength - 3) + '...';
} catch (e) {
// Fallback if URL parsing fails
return url.length > maxLength ? url.substring(0, maxLength - 3) + '...' : url;
}
}
// Display error message in the graph container
function displayGraphError(errorMessage) {
mermaidGraph.innerHTML = '';
const errorDiv = document.createElement('div');
errorDiv.className = 'mermaid-error';
errorDiv.innerHTML = `<p>Error rendering graph: ${errorMessage}</p>
<p>Please try a different URL or report this issue.</p>`;
mermaidGraph.appendChild(errorDiv);
}
function displayRedirects(redirects) {
if (!redirects || redirects.length === 0) {
showError('No redirect information received');
return;
}
redirects.forEach((redirect, index) => {
const li = document.createElement('li');
li.className = 'redirect-item';
if (!redirect.isSSL) {
li.classList.add('non-ssl');
}
if (redirect.final) {
li.classList.add('final');
}
// Create header with step info, status code, and duration
const header = document.createElement('div');
header.className = 'redirect-item-header';
// Step and status
const stepLabel = document.createElement('span');
const statusCode = redirect.statusCode ? ` (${redirect.statusCode} ${redirect.statusText || ''})` : '';
stepLabel.textContent = `Step ${index}:${statusCode}`;
stepLabel.className = 'step-label';
// Duration
const duration = document.createElement('span');
duration.className = 'redirect-duration';
const durationValue = redirect.duration !== undefined ? redirect.duration : 0;
duration.textContent = `${durationValue} ms`;
header.appendChild(stepLabel);
header.appendChild(duration);
// URL container with copy button
const urlContainer = document.createElement('div');
urlContainer.className = 'url-container';
// URL text
const urlDiv = document.createElement('div');
urlDiv.className = 'redirect-url';
urlDiv.textContent = redirect.url;
// Copy button
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-url-btn';
copyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1h-3a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>`;
copyBtn.setAttribute('aria-label', 'Copy URL to clipboard');
copyBtn.setAttribute('title', 'Copy URL to clipboard');
// Add copy functionality
copyBtn.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent toggling details when clicking the copy button
// Copy URL to clipboard
navigator.clipboard.writeText(redirect.url)
.then(() => {
// Show success feedback
const originalHTML = copyBtn.innerHTML;
copyBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</svg>`;
copyBtn.classList.add('copied');
// Reset button after a delay
setTimeout(() => {
copyBtn.innerHTML = originalHTML;
copyBtn.classList.remove('copied');
}, 2000);
})
.catch(err => {
console.error('Could not copy URL: ', err);
alert('Failed to copy URL. Please try again.');
});
});
urlContainer.appendChild(urlDiv);
urlContainer.appendChild(copyBtn);
li.appendChild(header);
li.appendChild(urlContainer);
// Create collapsible section for details
const detailsContainer = document.createElement('div');
detailsContainer.className = 'details-container';
// Create toggle button
const toggleButton = document.createElement('button');
toggleButton.className = 'toggle-details-btn';
toggleButton.textContent = 'Show Details';
toggleButton.addEventListener('click', () => {
const detailsContent = detailsContainer.querySelector('.details-content');
const isHidden = detailsContent.classList.contains('hidden');
if (isHidden) {
detailsContent.classList.remove('hidden');
toggleButton.textContent = 'Hide Details';
} else {
detailsContent.classList.add('hidden');
toggleButton.textContent = 'Show Details';
}
});
// Create details content (hidden by default)
const detailsContent = document.createElement('div');
detailsContent.className = 'details-content hidden';
// Add tabs for different types of info
const tabsContainer = document.createElement('div');
tabsContainer.className = 'tabs-container';
const tabs = [
{ id: 'headers', name: 'Headers' },
{ id: 'body', name: 'Response Body' },
{ id: 'metadata', name: 'Metadata' },
{ id: 'tracking', name: 'Tracking Params' },
{ id: 'ssl', name: 'SSL Info' }
];
const tabButtons = document.createElement('div');
tabButtons.className = 'tab-buttons';
const tabContents = document.createElement('div');
tabContents.className = 'tab-contents';
tabs.forEach((tab, tabIndex) => {
// Create tab button
const tabButton = document.createElement('button');
tabButton.className = 'tab-button';
tabButton.textContent = tab.name;
if (tabIndex === 0) tabButton.classList.add('active');
// Create tab content
const tabContent = document.createElement('div');
tabContent.className = 'tab-content';
tabContent.id = `${tab.id}-${index}`;
if (tabIndex !== 0) tabContent.classList.add('hidden');
// Add content based on tab type
switch (tab.id) {
case 'headers':
if (redirect.metadata && redirect.metadata.headers) {
const headersContent = document.createElement('pre');
headersContent.className = 'headers-content';
// Format headers
let headersText = '';
const headers = redirect.metadata.headers;
for (const key in headers) {
headersText += `${key}: ${headers[key]}\n`;
}
headersContent.textContent = headersText || 'No headers available';
tabContent.appendChild(headersContent);
} else {
tabContent.textContent = 'No headers available';
}
break;
case 'body':
const bodyContent = document.createElement('pre');
bodyContent.className = 'body-content';
bodyContent.textContent = redirect.responseBody || 'No response body available';
tabContent.appendChild(bodyContent);
break;
case 'metadata':
if (redirect.metadata) {
const metadataContent = document.createElement('pre');
metadataContent.className = 'metadata-content';
metadataContent.textContent = JSON.stringify(redirect.metadata, null, 2);
tabContent.appendChild(metadataContent);
} else {
tabContent.textContent = 'No metadata available';
}
break;
case 'tracking':
// Extract tracking parameters from the URL
const trackingParams = extractTrackingParams(redirect.url);
if (Object.keys(trackingParams).length > 0) {
// Create a table for better presentation
const trackingTable = document.createElement('table');
trackingTable.className = 'tracking-table';
// Add table header
const tableHeader = document.createElement('thead');
const headerRow = document.createElement('tr');
const headerParam = document.createElement('th');
headerParam.textContent = 'Parameter';
headerRow.appendChild(headerParam);
const headerValue = document.createElement('th');
headerValue.textContent = 'Value';
headerRow.appendChild(headerValue);
tableHeader.appendChild(headerRow);
trackingTable.appendChild(tableHeader);
// Add table body
const tableBody = document.createElement('tbody');
Object.entries(trackingParams).forEach(([param, value]) => {
const row = document.createElement('tr');
const paramCell = document.createElement('td');
paramCell.className = 'param-name';
paramCell.textContent = param;
row.appendChild(paramCell);
const valueCell = document.createElement('td');
valueCell.className = 'param-value';
valueCell.textContent = value;
row.appendChild(valueCell);
tableBody.appendChild(row);
});
trackingTable.appendChild(tableBody);
tabContent.appendChild(trackingTable);
} else {
tabContent.textContent = 'No tracking parameters detected in this URL';
}
break;
case 'ssl':
if (redirect.sslInfo) {
const sslContent = document.createElement('pre');
sslContent.className = 'ssl-content';
sslContent.textContent = JSON.stringify(redirect.sslInfo, null, 2);
tabContent.appendChild(sslContent);
} else {
tabContent.textContent = redirect.isSSL
? 'SSL certificate information not available'
: 'Not an SSL connection';
}
break;
}
// Add click handler for tab button
tabButton.addEventListener('click', () => {
// Deactivate all buttons and hide all contents
tabButtons.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
tabContents.querySelectorAll('.tab-content').forEach(content => content.classList.add('hidden'));
// Activate this button and show its content
tabButton.classList.add('active');
tabContent.classList.remove('hidden');
});
tabButtons.appendChild(tabButton);
tabContents.appendChild(tabContent);
});
tabsContainer.appendChild(tabButtons);
tabsContainer.appendChild(tabContents);
// Add error message if present
if (redirect.error) {
const errorInfo = document.createElement('div');
errorInfo.className = 'redirect-error';
errorInfo.textContent = `Error: ${redirect.error}`;
detailsContent.appendChild(errorInfo);
}
detailsContent.appendChild(tabsContainer);
detailsContainer.appendChild(toggleButton);
detailsContainer.appendChild(detailsContent);
li.appendChild(detailsContainer);
redirectList.appendChild(li);
});
showResults();
}
function addWarning(message) {
const warningDiv = document.createElement('div');
warningDiv.textContent = message;
warningContainer.appendChild(warningDiv);
}
function showWarnings() {
warningsDiv.classList.remove('hidden');
}
function hideWarnings() {
warningsDiv.classList.add('hidden');
warningContainer.innerHTML = '';
}
function showLoading() {
loading.classList.remove('hidden');
}
function hideLoading() {
loading.classList.add('hidden');
}
function showResults() {
results.classList.remove('hidden');
}
function hideResults() {
results.classList.add('hidden');
}
function showError(message) {
errorMessage.textContent = message;
errorDiv.classList.remove('hidden');
}
function hideError() {
errorDiv.classList.add('hidden');
}
// Add a helper function to extract tracking parameters from URL
function extractTrackingParams(url) {
try {
const parsedUrl = new URL(url);
const searchParams = parsedUrl.searchParams;
const trackingParams = {};
// Common tracking parameters to look for
const knownParams = [
// UTM parameters
{ key: 'utm_source', label: 'Source' },
{ key: 'utm_medium', label: 'Medium' },
{ key: 'utm_campaign', label: 'Campaign' },
{ key: 'utm_term', label: 'Term' },
{ key: 'utm_content', label: 'Content' },
// Facebook parameters
{ key: 'fbclid', label: 'Facebook Click ID' },
// Google parameters
{ key: 'gclid', label: 'Google Click ID' },
{ key: 'gclsrc', label: 'Google Click Source' },
// Other common parameters
{ key: 'source', label: 'Source' },
{ key: 'medium', label: 'Medium' },
{ key: 'campaign', label: 'Campaign' },
{ key: 'term', label: 'Term' },
{ key: 'content', label: 'Content' },
{ key: 'referrer', label: 'Referrer' },
{ key: 'ref', label: 'Referral' },
{ key: 'affiliate', label: 'Affiliate' },
{ key: 'channel', label: 'Channel' },
// Microsoft Advertising
{ key: 'msclkid', label: 'Microsoft Click ID' },
// TikTok
{ key: 'ttclid', label: 'TikTok Click ID' }
];
// Check for known parameters
knownParams.forEach(param => {
if (searchParams.has(param.key)) {
trackingParams[param.label] = searchParams.get(param.key);
}
});
// Also collect any other parameters that look like tracking params
for (const [key, value] of searchParams.entries()) {
if (!Object.values(trackingParams).includes(value)) {
if (
key.includes('utm_') ||
key.includes('ref') ||
key.includes('source') ||
key.includes('medium') ||
key.includes('campaign') ||
key.includes('clid') ||
key.includes('tracking') ||
key.includes('ad')
) {
trackingParams[key] = value;
}
}
}
return trackingParams;
} catch (e) {
console.error('Error parsing URL tracking params:', e);
return {};
}
}
function updateMermaidGraph(redirects) {
const mermaidGraph = document.getElementById('mermaidGraph');
if (!mermaidGraph) {
console.error('Mermaid graph container not found');
return;
}
try {
// Clear existing graph
mermaidGraph.innerHTML = '';
// Get the graph definition
const graphDefinition = generateMermaidGraph(redirects);
// Create a new div element for Mermaid
const newDiv = document.createElement('div');
newDiv.className = 'mermaid';
newDiv.textContent = graphDefinition;
// Add the div to the container
mermaidGraph.appendChild(newDiv);
// Render the graph
renderMermaidGraph();
} catch (e) {
console.error('Error updating Mermaid graph:', e);
displayGraphError(e.message);
}
}
function displayGraphError(message) {
const mermaidGraph = document.getElementById('mermaidGraph');
if (mermaidGraph) {
mermaidGraph.innerHTML = `<div class="error-message">Graph error: ${sanitizeMermaidText(message)}</div>`;
}
}
});