334 lines
13 KiB
JavaScript
334 lines
13 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* Map Routing Page Structure
|
|||
|
|
*
|
|||
|
|
* This script fully maps the UDM Pro routing page structure to understand:
|
|||
|
|
* - All sections and their locations
|
|||
|
|
* - Button placements and contexts
|
|||
|
|
* - How the page changes based on state
|
|||
|
|
* - Form locations and structures
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { chromium } from 'playwright';
|
|||
|
|
import { readFileSync } 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('🗺️ UDM Pro Routing Page Structure 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...');
|
|||
|
|
// Wait for dashboard to load first
|
|||
|
|
await page.waitForTimeout(3000);
|
|||
|
|
|
|||
|
|
// Navigate to routing page
|
|||
|
|
await page.goto(`${UDM_URL}/network/default/settings/routing`, { waitUntil: 'networkidle' });
|
|||
|
|
await page.waitForTimeout(5000);
|
|||
|
|
|
|||
|
|
// Wait for routing-specific content
|
|||
|
|
await page.waitForSelector('body', { timeout: 10000 });
|
|||
|
|
|
|||
|
|
// Check if we're actually on the routing page
|
|||
|
|
const currentUrl = page.url();
|
|||
|
|
console.log(` Current URL: ${currentUrl}`);
|
|||
|
|
|
|||
|
|
if (currentUrl.includes('/login')) {
|
|||
|
|
console.log(' ⚠️ Still on login page, waiting for redirect...');
|
|||
|
|
await page.waitForURL('**/settings/routing**', { timeout: 15000 }).catch(() => {});
|
|||
|
|
await page.waitForTimeout(5000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Wait for API calls to complete
|
|||
|
|
await page.waitForResponse(response =>
|
|||
|
|
response.url().includes('/rest/routing') ||
|
|||
|
|
response.url().includes('/settings/routing'),
|
|||
|
|
{ timeout: 10000 }
|
|||
|
|
).catch(() => {});
|
|||
|
|
|
|||
|
|
await page.waitForTimeout(5000); // Final wait for full render
|
|||
|
|
|
|||
|
|
console.log('3. Mapping page structure...\n');
|
|||
|
|
|
|||
|
|
// Get comprehensive page structure
|
|||
|
|
const pageStructure = await page.evaluate(() => {
|
|||
|
|
const structure = {
|
|||
|
|
url: window.location.href,
|
|||
|
|
title: document.title,
|
|||
|
|
sections: [],
|
|||
|
|
buttons: [],
|
|||
|
|
forms: [],
|
|||
|
|
tables: [],
|
|||
|
|
textContent: {},
|
|||
|
|
layout: {},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Find all major sections
|
|||
|
|
const sectionSelectors = [
|
|||
|
|
'main',
|
|||
|
|
'section',
|
|||
|
|
'[role="main"]',
|
|||
|
|
'[class*="container" i]',
|
|||
|
|
'[class*="section" i]',
|
|||
|
|
'[class*="panel" i]',
|
|||
|
|
'[class*="content" i]',
|
|||
|
|
'[class*="page" i]',
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
sectionSelectors.forEach(selector => {
|
|||
|
|
const elements = document.querySelectorAll(selector);
|
|||
|
|
elements.forEach((el, index) => {
|
|||
|
|
const rect = el.getBoundingClientRect();
|
|||
|
|
if (rect.width > 100 && rect.height > 100) {
|
|||
|
|
const text = el.textContent?.trim().substring(0, 200) || '';
|
|||
|
|
structure.sections.push({
|
|||
|
|
selector,
|
|||
|
|
index,
|
|||
|
|
tag: el.tagName,
|
|||
|
|
className: el.className || '',
|
|||
|
|
id: el.id || '',
|
|||
|
|
text: text,
|
|||
|
|
position: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|||
|
|
hasButtons: el.querySelectorAll('button').length,
|
|||
|
|
hasForms: el.querySelectorAll('form').length,
|
|||
|
|
hasTables: el.querySelectorAll('table').length,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Find all buttons with full context
|
|||
|
|
const buttons = Array.from(document.querySelectorAll('button, [role="button"], a[class*="button" i]'));
|
|||
|
|
buttons.forEach((btn, index) => {
|
|||
|
|
const rect = btn.getBoundingClientRect();
|
|||
|
|
if (rect.width > 0 && rect.height > 0) {
|
|||
|
|
const styles = window.getComputedStyle(btn);
|
|||
|
|
if (styles.display !== 'none' && styles.visibility !== 'hidden') {
|
|||
|
|
// Get parent context
|
|||
|
|
let parent = btn.parentElement;
|
|||
|
|
let parentContext = '';
|
|||
|
|
let depth = 0;
|
|||
|
|
while (parent && depth < 5) {
|
|||
|
|
const parentText = parent.textContent?.trim() || '';
|
|||
|
|
const parentClass = parent.className || '';
|
|||
|
|
if (parentText.length > 0 && parentText.length < 100) {
|
|||
|
|
parentContext = parentText + ' > ' + parentContext;
|
|||
|
|
}
|
|||
|
|
if (parentClass.includes('route') || parentClass.includes('routing') ||
|
|||
|
|
parentClass.includes('table') || parentClass.includes('header')) {
|
|||
|
|
parentContext = `[${parentClass.substring(0, 50)}] > ` + parentContext;
|
|||
|
|
}
|
|||
|
|
parent = parent.parentElement;
|
|||
|
|
depth++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
structure.buttons.push({
|
|||
|
|
index,
|
|||
|
|
tag: btn.tagName,
|
|||
|
|
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 },
|
|||
|
|
parentContext: parentContext.substring(0, 200),
|
|||
|
|
isVisible: true,
|
|||
|
|
isEnabled: !btn.disabled,
|
|||
|
|
hasIcon: btn.querySelector('svg') !== null,
|
|||
|
|
iconOnly: !btn.textContent?.trim() && btn.querySelector('svg') !== null,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Find all forms
|
|||
|
|
const forms = Array.from(document.querySelectorAll('form'));
|
|||
|
|
forms.forEach((form, index) => {
|
|||
|
|
const rect = form.getBoundingClientRect();
|
|||
|
|
if (rect.width > 0 && rect.height > 0) {
|
|||
|
|
const inputs = Array.from(form.querySelectorAll('input, select, textarea'));
|
|||
|
|
structure.forms.push({
|
|||
|
|
index,
|
|||
|
|
id: form.id || '',
|
|||
|
|
className: form.className || '',
|
|||
|
|
action: form.action || '',
|
|||
|
|
method: form.method || '',
|
|||
|
|
inputs: inputs.map(input => ({
|
|||
|
|
type: input.type || input.tagName,
|
|||
|
|
name: input.name || '',
|
|||
|
|
placeholder: input.placeholder || '',
|
|||
|
|
id: input.id || '',
|
|||
|
|
})),
|
|||
|
|
position: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Find 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 headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent?.trim() || '');
|
|||
|
|
const rows = Array.from(table.querySelectorAll('tbody tr')).length;
|
|||
|
|
structure.tables.push({
|
|||
|
|
index,
|
|||
|
|
className: table.className || '',
|
|||
|
|
id: table.id || '',
|
|||
|
|
headers,
|
|||
|
|
rowCount: rows,
|
|||
|
|
position: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|||
|
|
hasButtons: table.querySelectorAll('button').length,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Get page text content for context
|
|||
|
|
const bodyText = document.body.textContent || '';
|
|||
|
|
structure.textContent = {
|
|||
|
|
hasStaticRoutes: bodyText.includes('Static Routes') || bodyText.includes('Static Route'),
|
|||
|
|
hasRoutes: bodyText.includes('Route') && !bodyText.includes('Router'),
|
|||
|
|
hasAdd: bodyText.includes('Add') || bodyText.includes('Create') || bodyText.includes('New'),
|
|||
|
|
hasTable: bodyText.includes('table') || document.querySelector('table') !== null,
|
|||
|
|
fullText: bodyText.substring(0, 1000),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return structure;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log('📄 Page Structure:');
|
|||
|
|
console.log('='.repeat(80));
|
|||
|
|
console.log(`URL: ${pageStructure.url}`);
|
|||
|
|
console.log(`Title: ${pageStructure.title}`);
|
|||
|
|
console.log(`\nText Context:`);
|
|||
|
|
console.log(` Has "Static Routes": ${pageStructure.textContent.hasStaticRoutes}`);
|
|||
|
|
console.log(` Has "Route": ${pageStructure.textContent.hasRoutes}`);
|
|||
|
|
console.log(` Has "Add/Create": ${pageStructure.textContent.hasAdd}`);
|
|||
|
|
console.log(` Has Table: ${pageStructure.textContent.hasTable}`);
|
|||
|
|
|
|||
|
|
console.log(`\n📦 Sections (${pageStructure.sections.length}):`);
|
|||
|
|
pageStructure.sections.forEach((section, i) => {
|
|||
|
|
console.log(`\n${i + 1}. ${section.tag}.${section.className.substring(0, 50)}`);
|
|||
|
|
console.log(` Position: (${section.position.x}, ${section.position.y}) ${section.position.width}x${section.position.height}`);
|
|||
|
|
console.log(` Buttons: ${section.hasButtons}, Forms: ${section.hasForms}, Tables: ${section.hasTables}`);
|
|||
|
|
console.log(` Text: "${section.text.substring(0, 100)}"`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log(`\n🔘 Buttons (${pageStructure.buttons.length}):`);
|
|||
|
|
pageStructure.buttons.forEach((btn, i) => {
|
|||
|
|
console.log(`\n${i + 1}. Button ${btn.index}:`);
|
|||
|
|
console.log(` Tag: ${btn.tag}`);
|
|||
|
|
console.log(` Text: "${btn.text}"`);
|
|||
|
|
console.log(` Class: ${btn.className.substring(0, 80)}`);
|
|||
|
|
console.log(` ID: ${btn.id || 'none'}`);
|
|||
|
|
console.log(` Aria Label: ${btn.ariaLabel || 'none'}`);
|
|||
|
|
console.log(` Position: (${btn.position.x}, ${btn.position.y}) ${btn.position.width}x${btn.position.height}`);
|
|||
|
|
console.log(` Icon Only: ${btn.iconOnly}, Has Icon: ${btn.hasIcon}`);
|
|||
|
|
console.log(` Enabled: ${btn.isEnabled}`);
|
|||
|
|
console.log(` Context: ${btn.parentContext.substring(0, 150)}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log(`\n📋 Tables (${pageStructure.tables.length}):`);
|
|||
|
|
pageStructure.tables.forEach((table, i) => {
|
|||
|
|
console.log(`\n${i + 1}. Table ${table.index}:`);
|
|||
|
|
console.log(` Class: ${table.className.substring(0, 80)}`);
|
|||
|
|
console.log(` Headers: ${table.headers.join(', ')}`);
|
|||
|
|
console.log(` Rows: ${table.rowCount}`);
|
|||
|
|
console.log(` Buttons: ${table.hasButtons}`);
|
|||
|
|
console.log(` Position: (${table.position.x}, ${table.position.y})`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log(`\n📝 Forms (${pageStructure.forms.length}):`);
|
|||
|
|
pageStructure.forms.forEach((form, i) => {
|
|||
|
|
console.log(`\n${i + 1}. Form ${form.index}:`);
|
|||
|
|
console.log(` Inputs: ${form.inputs.length}`);
|
|||
|
|
form.inputs.forEach((input, j) => {
|
|||
|
|
console.log(` ${j + 1}. ${input.type} name="${input.name}" placeholder="${input.placeholder}"`);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Identify potential Add button
|
|||
|
|
console.log(`\n🎯 Potential Add Button Analysis:`);
|
|||
|
|
console.log('='.repeat(80));
|
|||
|
|
const iconOnlyButtons = pageStructure.buttons.filter(b => b.iconOnly);
|
|||
|
|
const buttonsNearRoutes = pageStructure.buttons.filter(b =>
|
|||
|
|
b.parentContext.toLowerCase().includes('route') ||
|
|||
|
|
b.parentContext.toLowerCase().includes('routing') ||
|
|||
|
|
b.parentContext.toLowerCase().includes('table')
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
console.log(`Icon-only buttons: ${iconOnlyButtons.length}`);
|
|||
|
|
iconOnlyButtons.forEach((btn, i) => {
|
|||
|
|
console.log(` ${i + 1}. ${btn.className.substring(0, 60)} - Position: (${btn.position.x}, ${btn.position.y})`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log(`\nButtons near routes/table: ${buttonsNearRoutes.length}`);
|
|||
|
|
buttonsNearRoutes.forEach((btn, i) => {
|
|||
|
|
console.log(` ${i + 1}. "${btn.text}" - ${btn.className.substring(0, 60)}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
console.log('\n\n⏸️ Page is open in browser. Inspect manually if needed.');
|
|||
|
|
console.log('Press Ctrl+C to close...\n');
|
|||
|
|
|
|||
|
|
// Keep browser open
|
|||
|
|
await page.waitForTimeout(60000);
|
|||
|
|
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('❌ Error:', error.message);
|
|||
|
|
await page.screenshot({ path: 'map-error.png', fullPage: true });
|
|||
|
|
} finally {
|
|||
|
|
await browser.close();
|
|||
|
|
}
|
|||
|
|
})();
|