docs(whp): rework DNS page for new Domains & DNS UI

The WHP DNS area was reworked from the old Domains page (left-side add
form + right-side DNS Management dropdown) into a searchable Domains &
DNS list plus a dedicated per-domain DNS records editor.

- Recapture whp-domains.png against the new list layout
- Add screenshots for the Add Domain modal, records editor, inline
  Add Record row, and bulk-action toolbar
- Add capture-dns.ts (fleet-redacted, viewport-only) following the
  existing capture-admin.ts pattern
- Rewrite add-a-domain.mdx Add Domain steps for the modal flow and
  point at the new records editor
- Add manage-dns-records.mdx how-to (add/edit/delete, type filter,
  bulk actions, verification, troubleshooting); renumber sidebar order

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:50:21 -07:00
parent 08adca6955
commit da24dc8c67
12 changed files with 267 additions and 12 deletions
+140
View File
@@ -0,0 +1,140 @@
/**
* DNS capture — the reworked "Domains & DNS" area.
*
* Captures, as the demo customer (so the demo domain's real zone shows):
* - whp-domains.png the Domains & DNS list (searchable table)
* - whp-dns-add-domain.png the Add Domain modal
* - whp-dns-records.png the per-domain DNS Records editor
* - whp-dns-add-record.png the inline "Add Record" editor row
* - whp-dns-bulk-actions.png the bulk-select toolbar
*
* Viewport-only (1440x900), redacted for our multi-server fleet: server /
* mail / nameserver hostnames and IPs become neutral placeholders, while the
* brand demo domain (whp-demo.anhh.co) is kept visible on purpose.
*
* Read-only: opens modals and ticks checkboxes for the screenshot, never
* saves, deletes, or submits anything.
*/
import { chromium, type Page } from 'playwright';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../../src/assets/screenshots/whp');
function need(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`missing env: ${name}`);
return v;
}
const BASE = need('WHP_BASE');
const USER = need('WHP_USER');
const PASS = need('WHP_PASS');
const DOMAIN = process.env.WHP_DEMO_DOMAIN ?? 'whp-demo.anhh.co';
const HIDE_CSS = `.navbar-text, .brand-full { visibility: hidden !important; }`;
async function login(page: Page) {
await page.goto(`${BASE}/login.php`, { waitUntil: 'domcontentloaded' });
await page.fill('input[name="user"]', USER);
await page.fill('input[name="password"]', PASS);
await page.click('button[type="submit"]');
await page.waitForLoadState('networkidle');
}
/**
* Neutralise fleet-identifying text before the screenshot. The brand demo
* domain (anhh.co) is intentionally preserved; everything that names a
* specific server, mail host, nameserver, or IP is swapped for a placeholder.
*/
async function redact(page: Page) {
await page.addStyleTag({ content: HIDE_CSS });
await page.evaluate(() => {
const swaps: [RegExp, string][] = [
[/ns[12]\.whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, 'ns<n>.<your-server>.cloud-hosting.io'],
[/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '<your-server>.cloud-hosting.io'],
[/mail\d+\.cloud-hosting\.io/gi, '<mail-server>.cloud-hosting.io'],
[/WHP\d+(-[A-Z0-9]+)?\b/g, '<YOUR-SERVER>'],
[/whp\d+(-[a-z0-9]+)?\b/gi, '<your-server>'],
// Public IPv4 (skip RFC1918 — those read fine as generic examples)
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '<server-IP>'],
[/demo-user/g, 'your-username'],
];
// Text nodes
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const nodes: Text[] = [];
let n: Node | null = walker.nextNode();
while (n) { nodes.push(n as Text); n = walker.nextNode(); }
for (const node of nodes) {
let v = node.nodeValue ?? '';
for (const [re, rep] of swaps) v = v.replace(re, rep);
if (v !== node.nodeValue) node.nodeValue = v;
}
// Input values (the inline Add Record / TTL fields)
document.querySelectorAll<HTMLInputElement>('input').forEach((el) => {
if (el.type === 'password' || !el.value) return;
let v = el.value;
for (const [re, rep] of swaps) v = v.replace(re, rep);
if (v !== el.value) el.value = v;
});
});
}
async function shot(page: Page, id: string) {
await page.waitForTimeout(400);
await redact(page);
const path = resolve(OUT_DIR, `${id}.png`);
await page.screenshot({ path, fullPage: false });
console.log(`captured ${id} -> ${path}`);
}
async function main() {
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 2,
});
const page = await ctx.newPage();
try {
await login(page);
// 1. Domains & DNS list
await page.goto(`${BASE}/index.php?page=domains`, { waitUntil: 'networkidle' });
await shot(page, 'whp-domains');
// 2. Add Domain modal
await page.locator('button:has-text("Add Domain"), a:has-text("Add Domain")').first().click();
await page.waitForTimeout(600);
await shot(page, 'whp-dns-add-domain');
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(300);
// 3. DNS Records editor for the demo domain
await page.goto(`${BASE}/index.php?page=domains&domain=${DOMAIN}`, { waitUntil: 'networkidle' });
await shot(page, 'whp-dns-records');
// 4. Inline Add Record row
await page.locator('button:has-text("Add Record"), a:has-text("Add Record")').first().click();
await page.waitForTimeout(500);
await shot(page, 'whp-dns-add-record');
// Cancel the inline add row so the next shot is clean
await page.locator('button:has-text("Cancel")').first().click().catch(() => {});
await page.waitForTimeout(400);
// 5. Bulk-select toolbar (tick two record rows)
const rowChecks = page.locator('table tbody input[type=checkbox]');
const n = await rowChecks.count();
if (n >= 2) { await rowChecks.nth(0).check(); await rowChecks.nth(1).check(); }
else if (n === 1) { await rowChecks.nth(0).check(); }
await page.waitForTimeout(500);
await shot(page, 'whp-dns-bulk-actions');
} finally {
await browser.close();
}
}
main().catch((err) => { console.error(err); process.exit(1); });