treeru.com
Tools

Automating Full-Page Screenshots of Web Admin UIs with Playwright — 170 Pages in 8 Minutes

2026-04-21
Treeru

For gear where the web GUI effectively is the config file — firewalls, routers, NAS boxes — you eventually want to “photograph everything right now.” Exporting the XML helps, but comparing yesterday to today visually is just easier with screenshots. The catch is that an admin UI usually has 100+ pages. So I wrote a standalone Playwright script that walks the sidebar end-to-end and saves a fullPage PNG per page. This post is a “here is a method — please steal it” write-up.

170+

Pages captured per run

~16MB

Total PNG size

8–10 min

Run time

$0

Tool cost (OSS)

Why I Needed It

I touch the firewall admin UI often: rule changes, DHCP reservations, VPN tweaks, NAT forwarding. After each change I almost always want to go back and confirm “is this actually the screen I intended?” Without a reference screen, that is nerve-wracking.

XML backups help, of course. But “what did the GUI look like on date X?”is something XML cannot reproduce. Dropdown options, current session counts, per-interface RX/TX counters — those only exist when you render the page. Dropping a dated folder of page captures each day turned out to be the best fit.

💡 When this pattern helps

  • Gear whose GUI is the configuration surface (firewalls, routers, NAS)
  • Products with thin or missing audit logs
  • Handing a page to an AI for review — you want the screenshot file path ready

Why a Standalone Script Over Playwright MCP

I first considered Playwright MCP (@playwright/mcp) so the AI could drive the browser directly. But most admin UIs use self-signed certificates. To get Playwright MCP past that you need to register it with --ignore-https-errors and then restart Claude Code. That is friction.

A standalone Node.js script avoids all of that.ignoreHTTPSErrors: true is a single line, and the script runs in the background independent of any AI session.

ApproachProsCons
Playwright MCPAI can reason while driving the browserCert flag + restart; inefficient for bulk loops
Standalone Node script170+ pages in one shot, background-friendly, cron-readyNo AI in the loop (post-hoc image analysis only)

Step 1 — Collect the Entire Sidebar

The script is split in two. The first, enumerate.js, logs in and then force-opens every sidebar submenu so every <a> is in the DOM, then serializes them to pages.json.

The trick is to call classList.add('opened') on every #mainmenu li and flip the hidden child UL to style.display = 'block'. No real click events required — once the DOM is expanded, a single map() gives you the entire menu tree flattened.

enumerate.js (key part)
const menu = await page.evaluate(() => {
  // Force-open every submenu
  document.querySelectorAll('#mainmenu li').forEach(li => {
    li.classList.add('opened');
    const sub = li.querySelector(':scope > ul');
    if (sub) sub.style.display = 'block';
  });
  return [...document.querySelectorAll('#mainmenu a')].map(a => ({
    text: (a.innerText || '').trim().replace(/\s+/g, ' '),
    href: a.getAttribute('href') || '',
  }));
});

// Dedup + drop external & dangerous links
const seen = new Set();
const DANGER = ['logout', 'reboot', 'halt', 'power'];
const pages = menu.filter(x => {
  const h = (x.href || '').split('#')[0];
  if (!h || seen.has(h)) return false;
  if (/^#/.test(x.href)) return false;
  if (/^https?:/i.test(x.href)) return false;
  const low = (x.href + ' ' + x.text).toLowerCase();
  if (DANGER.some(d => low.includes(d))) return false;
  seen.add(h);
  return true;
});

That filter alone usually trims ~200 raw candidates down to ~170 real pages by removing external help links, logout, reboot, and duplicate anchors. More on safety below.

Step 2 — Walk Each Page and Screenshot

shoot.js reads pages.json, visits each URL, and saves a fullPage: true PNG into a dated folder (YYMMDD), named NNN_<url-path>__<menu-name>.png.

Even after domcontentloaded fires, many dashboards render charts and graphs asynchronously. A single page.waitForTimeout(1200) fixes that. Without it, pages with heavy widgets get captured mid-spinner.

shoot.js (main loop)
for (let i = 0; i < pages.length; i++) {
  const { href, text } = pages[i];
  const num = String(i + 1).padStart(3, '0');
  const fname = `${num}_${sanitize(href, text)}.png`;
  const full = path.join(OUT, fname);
  try {
    await page.goto(BASE + href, { waitUntil: 'domcontentloaded', timeout: 20000 });
    await page.waitForTimeout(1200);          // let widgets settle
    await page.screenshot({ path: full, fullPage: true });
    index.push({ num, text, href, file: fname, status: 'ok' });
  } catch (e) {
    index.push({ num, text, href, file: fname, status: 'fail', error: e.message.split('\n')[0] });
  }
}

// Emit two index files at the end
fs.writeFileSync(path.join(OUT, '_index.json'), JSON.stringify(index, null, 2));
fs.writeFileSync(path.join(OUT, '_index.md'), renderMarkdownTable(index));

The run ends with an _index.md mapping number → menu → URL → file → status, which makes “find that one screen I changed last week” trivial.

Filter Out Dangerous Links

Admin sidebars hide links you really do not want clicked. Miss them on the first run and the script reboots the firewall or kills the session mid-loop, after which it just keeps re-capturing the login screen. I learned this the hard way.

⚠️ Must-exclude patterns

  • logout — kills the session; every later page fails
  • reboot — reboots the device; possibly drops your whole network
  • halt / power — powers the device down
  • Anything starting with https?:// — external docs, not worth capturing
  • Anything starting with # — anchor, re-photographs the same page

Filter on lowercased href + text together. Translated UIs may label the button “Restart” or “재부팅,” but the URL keyword tends to stay English, which makes URL-level filtering safer.

How I Actually Use It

My real workflow:

  1. Pre-change snapshot.

    Before a big batch of firewall-rule edits I run DATE=YYMMDD node shoot.js to leave a “rollback reference.”

  2. Weekly cadence.

    A Monday-morning run makes it easy to answer “who changed what this week?” without rummaging through logs. (cron-able — see wrap-up.)

  3. Evidence when asking an AI.

    From a CLI like Claude Code, when I ask “this NAT rule looks off — take a look,” I attach the screenshot path directly. Image context noticeably improves AI accuracy.

  4. Eyeball diff across two folders.

    Line up yesterday's folder and today's folder side-by-side in the file manager. Because filenames match, a preview swipe reveals changes instantly. Pixel diff is rarely necessary.

ℹ️ Credential handling

Do not hardcode the password. Put it in a chmod 600 file in your home directory (e.g., ~/.admin_creds) as two lines — USER=...and PASS=... — and have the script read it. Simplest guard against accidental commits.

Pitfalls

  • Form selectors change. After admin-UI upgrades, names like input[name="usernamefld"] may shift. If login fails, open the login page source and re-check the field names.
  • Log and graph pages are slow. If 1.2s is not enough, special-case those URL patterns (e.g. /diag_logs/) with a longer wait.
  • Self-signed certs. Never drop ignoreHTTPSErrors: true — the moment you do, every request fails.
  • Run in background inside Claude Code. Bash has a 2-minute default timeout; an 8–10 minute script needs node shoot.js > shoot.log 2>&1 & plus a tail.
  • Screenshots leak sensitive data. VPN shared keys, user lists, certificate material can all appear on screen. Never commit the output folder to a public repo. Add the dated folder pattern to .gitignore.

Wrap-Up

Two scripts and roughly 100 lines of code buy you “a full visual backup of the admin UI as of today.” The only load-bearing ideas are force-open the sidebar, take fullPage PNGs, and filter dangerous links. The same pattern transfers to NAS UIs, router dashboards, and on-prem monitoring consoles with essentially zero changes.

Natural next steps: (1) cron it weekly, (2) image-diff against yesterday and report only changed pages, (3) pull the password from 1Password CLI or pass instead of a plain file. I have done (1); (2) is still on my list.

One-line summary: if the device only has a web UI, let Playwright stack a dated folder of everything it renders. Config restores, AI reviews, and team hand-offs all get easier from there.

T

Treeru

Sharing practical insights on web development, IT infrastructure, and AI solutions. Treeru — your partner in digital transformation.

Share

Comments

(0)

Log in to leave a comment.

Related Posts

© 2026 TreeRU. All rights reserved.

All content is copyrighted by TreeRU. Unauthorized reproduction without attribution is prohibited.