Files
proxmox/scripts/unifi/comprehensive-page-mapper.js

370 lines
14 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* Comprehensive Page Mapper
*
* This script fully maps the UDM Pro routing page to understand:
* - All UI elements and their relationships
* - Page state and how it changes
* - Where buttons appear based on context
* - How scrolling and interactions affect element visibility
*/
import { chromium } from 'playwright';
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
// Load environment variables
const envPath = join(homedir(), '.env');
function loadEnvFile(filePath) {
try {
const envFile = readFileSync(filePath, 'utf8');
const envVars = envFile.split('\n').filter(
(line) => line.includes('=') && !line.trim().startsWith('#')
);
for (const line of envVars) {
const [key, ...values] = line.split('=');
if (key && values.length > 0 && /^[A-Z_][A-Z0-9_]*$/.test(key.trim())) {
let value = values.join('=').trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
process.env[key.trim()] = value;
}
}
return true;
} catch {
return false;
}
}
loadEnvFile(envPath);
const UDM_URL = process.env.UNIFI_UDM_URL || 'https://192.168.0.1';
const USERNAME = process.env.UNIFI_BROWSER_USERNAME || process.env.UNIFI_USERNAME || 'unifi_api';
const PASSWORD = process.env.UNIFI_BROWSER_PASSWORD || process.env.UNIFI_PASSWORD;
console.log('🗺️ Comprehensive UDM Pro Page Mapper');
console.log('=====================================\n');
if (!PASSWORD) {
console.error('❌ UNIFI_PASSWORD must be set in ~/.env');
process.exit(1);
}
(async () => {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
console.log('1. Logging in...');
await page.goto(UDM_URL, { waitUntil: 'networkidle' });
await page.waitForSelector('input[type="text"]');
await page.fill('input[type="text"]', USERNAME);
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForTimeout(5000);
console.log('2. Navigating to Routing page...');
// First ensure we're logged in and on dashboard
const currentUrl = page.url();
if (currentUrl.includes('/login')) {
console.log(' Still on login, waiting for redirect...');
await page.waitForURL('**/network/**', { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(3000);
}
// Navigate to routing page
console.log(' Navigating to routing settings...');
await page.goto(`${UDM_URL}/network/default/settings/routing`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
// Wait for URL to be correct (handle redirects)
try {
await page.waitForURL('**/settings/routing**', { timeout: 20000 });
console.log(' ✅ On routing page');
} catch (error) {
console.log(` ⚠️ URL check failed, current: ${page.url()}`);
// Try navigating again
await page.goto(`${UDM_URL}/network/default/settings/routing`, { waitUntil: 'networkidle' });
await page.waitForTimeout(5000);
}
// Verify we're on the right page
const pageText = await page.textContent('body').catch(() => '');
if (!pageText.includes('Route') && !pageText.includes('routing') && !pageText.includes('Static')) {
console.log(' ⚠️ Page may not be fully loaded, waiting more...');
await page.waitForTimeout(10000);
}
console.log(` Current URL: ${page.url()}`);
// Wait for routes API
try {
await page.waitForResponse(response =>
response.url().includes('/rest/routing') || response.url().includes('/trafficroutes'),
{ timeout: 15000 }
);
console.log(' Routes API loaded');
} catch (error) {
console.log(' Routes API not detected');
}
await page.waitForTimeout(5000);
console.log('3. Comprehensive page mapping...\n');
// Take full page screenshot
await page.screenshot({ path: 'mapper-full-page.png', fullPage: true });
console.log(' Screenshot saved: mapper-full-page.png');
// Map page at different scroll positions
const scrollPositions = [0, 500, 1000, 1500, 2000];
const pageMaps = [];
for (const scrollY of scrollPositions) {
await page.evaluate((y) => window.scrollTo(0, y), scrollY);
await page.waitForTimeout(2000);
const map = await page.evaluate(() => {
const result = {
scrollY: window.scrollY,
viewport: { width: window.innerWidth, height: window.innerHeight },
elements: {
buttons: [],
links: [],
inputs: [],
tables: [],
sections: [],
text: [],
},
};
// Get all buttons with full context
const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
buttons.forEach((btn, index) => {
const rect = btn.getBoundingClientRect();
const styles = window.getComputedStyle(btn);
if (rect.width > 0 && rect.height > 0 && styles.display !== 'none') {
// Check if in viewport
const inViewport = rect.top >= 0 && rect.top < window.innerHeight &&
rect.left >= 0 && rect.left < window.innerWidth;
if (inViewport) {
// Get full parent hierarchy
let parent = btn;
const hierarchy = [];
for (let i = 0; i < 5; i++) {
parent = parent.parentElement;
if (!parent || parent === document.body) break;
const parentText = parent.textContent?.trim().substring(0, 100) || '';
const parentClass = parent.className || '';
hierarchy.push({
tag: parent.tagName,
class: parentClass.substring(0, 80),
text: parentText,
hasRoute: parentText.includes('Route') || parentText.includes('Static'),
hasTable: parent.tagName === 'TABLE' || parent.querySelector('table'),
});
}
result.elements.buttons.push({
index,
text: btn.textContent?.trim() || '',
className: btn.className || '',
id: btn.id || '',
ariaLabel: btn.getAttribute('aria-label') || '',
dataTestId: btn.getAttribute('data-testid') || '',
position: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
iconOnly: !btn.textContent?.trim() && btn.querySelector('svg'),
enabled: !btn.disabled,
visible: styles.visibility !== 'hidden',
hierarchy: hierarchy.slice(0, 3), // Top 3 parents
});
}
}
});
// Get all tables
const tables = Array.from(document.querySelectorAll('table'));
tables.forEach((table, index) => {
const rect = table.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const inViewport = rect.top >= 0 && rect.top < window.innerHeight;
if (inViewport) {
const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent?.trim() || '');
const rows = Array.from(table.querySelectorAll('tbody tr, tr')).length;
const tableText = table.textContent || '';
result.elements.tables.push({
index,
headers,
rowCount: rows,
hasRouteText: tableText.includes('Route') || tableText.includes('Static'),
position: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
buttonsInTable: Array.from(table.querySelectorAll('button')).length,
});
}
}
});
// Get all text content that might indicate sections
const routeTexts = [];
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
let node;
while (node = walker.nextNode()) {
const text = node.textContent?.trim() || '';
if (text && (text.includes('Static Routes') || text.includes('Route') || text.includes('Add'))) {
const parent = node.parentElement;
if (parent) {
routeTexts.push({
text: text.substring(0, 100),
tag: parent.tagName,
className: parent.className?.substring(0, 80) || '',
position: parent.getBoundingClientRect(),
});
}
}
}
result.elements.text = routeTexts.slice(0, 20);
return result;
});
pageMaps.push(map);
}
// Analyze results
console.log('📊 Page Mapping Results:');
console.log('='.repeat(80));
// Combine all buttons found at different scroll positions
const allButtons = new Map();
pageMaps.forEach((map, scrollIndex) => {
map.elements.buttons.forEach(btn => {
const key = btn.id || btn.className || `${btn.position.x}-${btn.position.y}`;
if (!allButtons.has(key)) {
allButtons.set(key, { ...btn, foundAtScroll: [scrollIndex] });
} else {
allButtons.get(key).foundAtScroll.push(scrollIndex);
}
});
});
console.log(`\n🔘 Unique Buttons Found: ${allButtons.size}`);
Array.from(allButtons.values()).forEach((btn, i) => {
console.log(`\n${i + 1}. Button:`);
console.log(` Text: "${btn.text}"`);
console.log(` Class: ${btn.className.substring(0, 80)}`);
console.log(` ID: ${btn.id || 'none'}`);
console.log(` Position: (${btn.position.x}, ${btn.position.y})`);
console.log(` Icon Only: ${btn.iconOnly}`);
console.log(` Enabled: ${btn.enabled}`);
console.log(` Found at scroll positions: ${btn.foundAtScroll.join(', ')}`);
if (btn.hierarchy.length > 0) {
console.log(` Parent Context:`);
btn.hierarchy.forEach((parent, j) => {
console.log(` ${j + 1}. <${parent.tag}> ${parent.class} - "${parent.text.substring(0, 50)}"`);
console.log(` Has Route: ${parent.hasRoute}, Has Table: ${parent.hasTable}`);
});
}
});
// Find tables
const allTables = [];
pageMaps.forEach(map => {
map.elements.tables.forEach(table => {
if (!allTables.find(t => t.index === table.index)) {
allTables.push(table);
}
});
});
console.log(`\n📋 Tables Found: ${allTables.length}`);
allTables.forEach((table, i) => {
console.log(`\n${i + 1}. Table ${table.index}:`);
console.log(` Headers: ${table.headers.join(', ')}`);
console.log(` Rows: ${table.rowCount}`);
console.log(` Has Route Text: ${table.hasRouteText}`);
console.log(` Buttons in Table: ${table.buttonsInTable}`);
console.log(` Position: (${table.position.x}, ${table.position.y})`);
});
// Find route-related text
const routeTexts = [];
pageMaps.forEach(map => {
map.elements.text.forEach(text => {
if (!routeTexts.find(t => t.text === text.text)) {
routeTexts.push(text);
}
});
});
console.log(`\n📝 Route-Related Text Found: ${routeTexts.length}`);
routeTexts.forEach((text, i) => {
console.log(`\n${i + 1}. "${text.text}"`);
console.log(` Tag: ${text.tag}, Class: ${text.className}`);
console.log(` Position: (${text.position.x}, ${text.position.y})`);
});
// Save full map to file
const mapData = {
url: page.url(),
timestamp: new Date().toISOString(),
scrollMaps: pageMaps,
allButtons: Array.from(allButtons.values()),
allTables,
routeTexts,
};
writeFileSync('page-map.json', JSON.stringify(mapData, null, 2));
console.log('\n💾 Full page map saved to: page-map.json');
// Identify most likely Add button
console.log(`\n🎯 Most Likely Add Button Candidates:`);
console.log('='.repeat(80));
const candidates = Array.from(allButtons.values())
.filter(btn => btn.iconOnly || btn.text.toLowerCase().includes('add') || btn.text.toLowerCase().includes('create'))
.sort((a, b) => {
// Prioritize buttons with route context
const aHasRoute = a.hierarchy.some(p => p.hasRoute);
const bHasRoute = b.hierarchy.some(p => p.hasRoute);
if (aHasRoute && !bHasRoute) return -1;
if (!aHasRoute && bHasRoute) return 1;
// Then prioritize buttons near tables
const aNearTable = a.hierarchy.some(p => p.hasTable);
const bNearTable = b.hierarchy.some(p => p.hasTable);
if (aNearTable && !bNearTable) return -1;
if (!aNearTable && bNearTable) return 1;
return 0;
});
candidates.slice(0, 5).forEach((btn, i) => {
console.log(`\n${i + 1}. "${btn.text}" - ${btn.className.substring(0, 60)}`);
console.log(` ID: ${btn.id || 'none'}`);
console.log(` Has Route Context: ${btn.hierarchy.some(p => p.hasRoute)}`);
console.log(` Near Table: ${btn.hierarchy.some(p => p.hasTable)}`);
console.log(` Selector: ${btn.id ? `#${btn.id}` : `.${btn.className.split(' ')[0]}`}`);
});
console.log('\n\n⏸ Page is open in browser. Inspect manually if needed.');
console.log('Press Ctrl+C to close...\n');
await page.waitForTimeout(60000);
} catch (error) {
console.error('❌ Error:', error.message);
await page.screenshot({ path: 'mapper-error.png', fullPage: true });
} finally {
await browser.close();
}
})();