docs: verify against real WHP + capture real screenshots

Discovery against the demo account on whp01 surfaced several inaccuracies:

- Cache is Valkey (Redis wire-compatible), not Redis or Memcached.
  No Memcached is offered as a separate service.
- Site Monitoring is the sidebar label (not 'AI Monitor').
- 'Add a domain' has no Primary/Add-on distinction.
- Sites form: 'Container Type' (not 'Site type'), Number of Containers
  (1-10 for horizontal scaling), CPU per Container (default 0.25),
  Memory per Container (default 256MB), SSL inline on the same form.
- Backups: default retention 5 days / 10 backups; on-demand + scheduled;
  S3 backup targets are visible and configurable.
- Email: per-domain settings live behind 'Setup Instructions' on the
  Email page; mail server hostname is on the Dashboard (per-server,
  e.g. mail01.cloud-hosting.io), not per-domain.

Also reworked the screenshot pipeline:
- New shots.config.ts targets the real index.php?page=... URLs
- Added redactSensitive() step that runs before each screenshot to swap
  server names, IPs, mail hostnames, and demo-user-isms with neutral
  placeholders. This keeps docs portable across the fleet.
- Hides .brand-full and .navbar-text (top-bar server identifier and
  Welcome greeting).
- Captured 9 real WHP screenshots; removed stale placeholders.
This commit is contained in:
2026-05-17 17:00:13 -07:00
parent 53bc37fd0d
commit c602b8f8f3
32 changed files with 460 additions and 152 deletions

View File

@@ -0,0 +1,98 @@
[
{
"text": "Logout",
"href": "index.php?whp-action=logout"
},
{
"text": "Dashboard",
"href": "index.php?page=dashboard"
},
{
"text": "Sites",
"href": "index.php?page=sites"
},
{
"text": "Traffic",
"href": "index.php?page=site-traffic"
},
{
"text": "Site Builder BETA",
"href": "index.php?page=site-builder"
},
{
"text": "Domains",
"href": "index.php?page=domains"
},
{
"text": "WordPress",
"href": "index.php?page=wordpress"
},
{
"text": "File Manager",
"href": "./filemanager/"
},
{
"text": "Terminal",
"href": "index.php?page=terminal"
},
{
"text": "cPanel Import",
"href": "index.php?page=cpanel-import"
},
{
"text": "MySQL Management",
"href": "index.php?page=database-management"
},
{
"text": "phpMyAdmin",
"href": "./phpmyadmin-sso.php"
},
{
"text": "PostgreSQL",
"href": "index.php?page=postgresql-management"
},
{
"text": "Adminer",
"href": "./adminer-sso.php"
},
{
"text": "Valkey Cache",
"href": "index.php?page=account-valkey"
},
{
"text": "Email",
"href": "index.php?page=email-management"
},
{
"text": "Backups",
"href": "index.php?page=backups"
},
{
"text": "Site Monitoring",
"href": "index.php?page=ai-monitor-customer"
},
{
"text": "Delegated Users",
"href": "index.php?page=delegated-users"
},
{
"text": "Active Sessions",
"href": "index.php?page=active-sessions"
},
{
"text": "Cloud Apache Container",
"href": "https://repo.anhonesthost.net/cloud-hosting-platform/cloud-apache-container"
},
{
"text": "Cloud Node Container",
"href": "https://repo.anhonesthost.net/cloud-hosting-platform/cloud-node-container"
},
{
"text": "Manage Domains",
"href": "index.php?page=domains"
},
{
"text": "Manage Databases",
"href": "index.php?page=database-management"
}
]

View File

@@ -0,0 +1,109 @@
/**
* Discovery script — logs in, captures the dashboard, and probes the sidebar
* nav to learn the actual URLs of the sections referenced in our docs.
*
* Not committed for ongoing use; the canonical capture is run.ts.
*/
import { chromium, type Page } from 'playwright';
import { mkdir, writeFile } 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');
async function login(page: Page): Promise<void> {
console.log(`navigating ${BASE}/login.php`);
await page.goto(`${BASE}/login.php`, { waitUntil: 'domcontentloaded' });
console.log(`landed at ${page.url()}`);
await page.fill('input[name="user"]', USER);
await page.fill('input[name="password"]', PASS);
await page.click('button[type="submit"]');
await page.waitForLoadState('networkidle');
console.log(`logged in, now at ${page.url()}`);
}
async function snapshotPage(page: Page, id: string): Promise<void> {
await mkdir(OUT_DIR, { recursive: true });
const path = resolve(OUT_DIR, `${id}.png`);
await page.screenshot({ path, fullPage: false });
console.log(`captured ${id} -> ${path}`);
}
async function listNavLinks(page: Page): Promise<{ text: string; href: string | null }[]> {
// Use locator API to collect all anchors with hrefs (no string-eval).
const anchors = page.locator('a[href]');
const count = await anchors.count();
const out: { text: string; href: string | null }[] = [];
for (let i = 0; i < count && out.length < 120; i++) {
const a = anchors.nth(i);
const href = await a.getAttribute('href');
if (!href || href.startsWith('#')) continue;
const text = ((await a.textContent()) ?? '').trim().slice(0, 60);
if (!text) continue;
out.push({ text, href });
}
return out;
}
async function probe(page: Page, label: string, hrefHint: string): Promise<string | null> {
const link = page.locator(`a[href*="${hrefHint}"]`).first();
if ((await link.count()) === 0) {
console.log(` NO MATCH for "${label}" (hint=${hrefHint})`);
return null;
}
const href = await link.getAttribute('href');
console.log(` ${label}: ${href}`);
return href;
}
async function main(): Promise<void> {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1440, height: 900 },
});
const page = await ctx.newPage();
try {
await login(page);
await snapshotPage(page, '_discovery-dashboard');
const navLinks = await listNavLinks(page);
await writeFile(
resolve(__dirname, '_discovered-nav.json'),
JSON.stringify(navLinks, null, 2),
);
console.log(`wrote ${navLinks.length} nav links to _discovered-nav.json`);
for (const [label, hint] of [
['Domains', 'domain'],
['Sites', 'site'],
['Email', 'email'],
['Backups', 'backup'],
['Monitor', 'monitor'],
['Resources', 'resource'],
['Dashboard', 'dashboard'],
] as const) {
await probe(page, label, hint);
}
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

53
tools/screenshots/peek.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* One-shot peek script: log in, navigate to a specific URL, screenshot it.
* Usage: PEEK_URL=... npx tsx tools/screenshots/peek.ts
*/
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 PEEK = need('PEEK_URL');
const ID = process.env.PEEK_ID ?? '_peek';
async function login(page: Page): Promise<void> {
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');
}
async function main(): Promise<void> {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
try {
await login(page);
await page.goto(`${BASE}${PEEK}`);
await page.waitForLoadState('networkidle');
await mkdir(OUT_DIR, { recursive: true });
const out = resolve(OUT_DIR, `${ID}.png`);
await page.screenshot({ path: out, fullPage: true });
console.log(`captured ${PEEK} -> ${out}`);
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -22,13 +22,57 @@ const WHP_USER = envOrDie('WHP_USER');
const WHP_PASS = envOrDie('WHP_PASS');
async function login(page: Page): Promise<void> {
await page.goto(`${WHP_BASE}/login`);
await page.fill('input[name="username"]', WHP_USER);
await page.goto(`${WHP_BASE}/login.php`);
await page.fill('input[name="user"]', WHP_USER);
await page.fill('input[name="password"]', WHP_PASS);
await page.click('button[type="submit"]');
await page.waitForLoadState('networkidle');
}
/**
* Neutralise server-identifying and account-specific text/labels before the
* screenshot — we run a multi-server fleet, so docs shouldn't bake in a single
* host's name or IP.
*
* Runs in the page context. Self-contained: no external bindings, no eval.
*/
async function redactSensitive(page: Page): Promise<void> {
await page.addStyleTag({
content: `
.navbar-text { visibility: hidden !important; }
.brand-full { visibility: hidden !important; }
`,
});
// String swaps for inline text that mask-by-selector can't cover.
await page.evaluate(() => {
const root = document.body;
const swaps: [RegExp, string][] = [
// server / mail / nameserver hostnames (with possible -s3 etc. suffix)
[/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '<your-server>.cloud-hosting.io'],
[/mail\d+\.cloud-hosting\.io/gi, '<your-mail-server>.cloud-hosting.io'],
[/ns[12]\.whp\d+\.cloud-hosting\.io/gi, 'ns<n>.your-server.cloud-hosting.io'],
// backup target / bucket names that bake in server number
[/whp\d+(-[a-z0-9]+)?\b/gi, '<your-server>'],
[/WHP\d+(-[A-Z0-9]+)?\b/g, '<YOUR-SERVER>'],
// IP addresses
[/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '<your-server-IP>'],
// home dir + demo user
[/\/docker\/users\/[a-z0-9-]+/g, '/docker/users/<your-username>'],
[/demo-user/g, 'your-username'],
];
const walker = document.createTreeWalker(root, 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, replacement] of swaps) v = v.replace(re, replacement);
if (v !== node.nodeValue) node.nodeValue = v;
}
});
}
async function captureShot(page: Page, shot: Shot): Promise<void> {
const viewport = shot.viewport ?? { width: 1440, height: 900 };
await page.setViewportSize(viewport);
@@ -36,6 +80,7 @@ async function captureShot(page: Page, shot: Shot): Promise<void> {
await page.goto(`${WHP_BASE}${shot.path}`);
await page.waitForLoadState('networkidle');
if (shot.waitFor) await page.waitForSelector(shot.waitFor);
await redactSensitive(page);
const maskSelectors = [...DEFAULT_MASK, ...(shot.mask ?? [])];
const maskLocators: Locator[] = maskSelectors.map((sel) => page.locator(sel));

View File

@@ -15,19 +15,18 @@ export type Shot = {
/** Always-applied redactions. Per-shot mask is added on top of this list. */
export const DEFAULT_MASK: string[] = [
'[data-test="account-id"]',
'[data-test="server-hostname"]',
'[data-test="user-ip"]',
'.billing-column',
// Conservative defaults — the actual selectors may differ; verify by inspecting
// a captured shot and adding more selectors here if anything leaks.
];
export const shots: Shot[] = [
{ id: 'whp-domains-add', path: '/domains/add' },
{ id: 'whp-sites-add', path: '/sites/add' },
{ id: 'whp-email-add', path: '/email/add' },
{ id: 'whp-backups-settings', path: '/backups/settings' },
{ id: 'whp-backups-history', path: '/backups/history' },
{ id: 'whp-monitor', path: '/security/monitor' },
{ id: 'whp-email-archive', path: '/email/archive' },
{ id: 'whp-resources', path: '/overview/resources' },
{ id: 'whp-dashboard', path: '/index.php?page=dashboard' },
{ id: 'whp-domains', path: '/index.php?page=domains' },
{ id: 'whp-sites', path: '/index.php?page=sites' },
{ id: 'whp-email', path: '/index.php?page=email-management' },
{ id: 'whp-backups', path: '/index.php?page=backups' },
{ id: 'whp-site-monitoring', path: '/index.php?page=ai-monitor-customer' },
{ id: 'whp-mysql', path: '/index.php?page=database-management' },
{ id: 'whp-postgres', path: '/index.php?page=postgresql-management' },
{ id: 'whp-valkey', path: '/index.php?page=account-valkey' },
];