Initial commit: URL Redirect Tracker application with comprehensive documentation

This commit is contained in:
Andrei
2025-08-18 06:37:57 +00:00
commit a5d19c8549
1580 changed files with 211670 additions and 0 deletions

838
public/script.js Normal file
View File

@@ -0,0 +1,838 @@
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>`;
}
}
});