diff --git a/src/content/docs/whp/add-ons/archival-email.mdx b/src/content/docs/whp/add-ons/archival-email.mdx index 0825bb8..c237b42 100644 --- a/src/content/docs/whp/add-ons/archival-email.mdx +++ b/src/content/docs/whp/add-ons/archival-email.mdx @@ -18,7 +18,7 @@ Archival email keeps a long-term, searchable copy of your mail **outside** the l - Compliance or policy requires you keep email long-term. - You want a recovery option for mail you accidentally delete from the live mailbox. -It's powered by our [Bichon](https://anhonesthost.com/bichon/) archival service. +It's powered by the open-source [Bichon](https://github.com/rustmailer/bichon) archival service. ## How it's different from backups diff --git a/tools/screenshots/capture-traffic.ts b/tools/screenshots/capture-traffic.ts new file mode 100644 index 0000000..171704e --- /dev/null +++ b/tools/screenshots/capture-traffic.ts @@ -0,0 +1,116 @@ +/** + * Traffic analytics capture — for the June 2026 platform-updates blog post. + * + * Captures, as the demo customer: + * - traffic-analytics-overview.png View Traffic landing: Yesterday's Snapshot, + * Top URLs / Bandwidth Consumers, Daily Totals + * - traffic-analytics-day-detail.png the per-day drill-down: hourly request graph, + * top pages by views, top pages by bandwidth + * + * Viewport 1440x900, deviceScaleFactor 2, fullPage:false. Redacts the fleet + * server strip ("WHP-01" / "Welcome, ...") and the version-number footer; keeps + * the brand demo domain (whp-demo.anhh.co) visible on purpose. Read-only. + * + * Output goes to /workspace/blog-assets (this is a blog image, not a KB page). + */ +import { chromium, type Page } from 'playwright'; +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const OUT_DIR = '/workspace/blog-assets'; + +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'); + +// Hide the server-identifying navbar strip and the version footer. +const HIDE_CSS = `.navbar-text, .brand-full, .navbar-brand { 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'); +} + +async function redact(page: Page) { + await page.addStyleTag({ content: HIDE_CSS }); + await page.evaluate(() => { + const swaps: [RegExp, string][] = [ + [/whp\d+(-[a-z0-9]+)?\.cloud-hosting\.io/gi, '.cloud-hosting.io'], + [/WHP\d+(-[A-Z0-9]+)?\b/g, ''], + [/whp\d+(-[a-z0-9]+)?\b/gi, ''], + [/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, ''], + [/demo-user/g, 'your-username'], + // Strip the release/version identifier from the footer. + [/Web Hosting Panel\s*-\s*\d{4}\.\d{2}\.\d+/gi, 'Web Hosting Panel'], + [/\b\d{4}\.\d{2}\.\d+\b/g, ''], + ]; + 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; + } + }); +} + +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); + + // Resolve the demo site's traffic view via the "View Traffic" button. + await page.goto(`${BASE}/index.php?page=site-traffic`, { waitUntil: 'networkidle' }); + await page.locator('a:has-text("View Traffic"), button:has-text("View Traffic")').first().click(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1000); + const trafficUrl = page.url(); + + // 1. Overview — clip to the main content card so the snapshot + daily totals + // frame nicely without the empty side gutters. + await redact(page); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.waitForTimeout(300); + await page.screenshot({ path: resolve(OUT_DIR, 'traffic-analytics-overview.png'), fullPage: false }); + console.log('captured traffic-analytics-overview'); + + // 2. Day drill-down — click the 23rd (richest demo data), then clip to the + // "Breakdown for ..." card (hourly graph + top pages + bandwidth). + await page.locator('a:has-text("2026-06-23")').first().click(); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(1500); + await redact(page); + const card = page.locator('#day-detail, .card:has-text("Breakdown for")').first(); + await card.scrollIntoViewIfNeeded().catch(() => {}); + await page.waitForTimeout(400); + if (await card.count()) { + await card.screenshot({ path: resolve(OUT_DIR, 'traffic-analytics-day-detail.png') }); + } else { + await page.screenshot({ path: resolve(OUT_DIR, 'traffic-analytics-day-detail.png'), fullPage: false }); + } + console.log('captured traffic-analytics-day-detail'); + console.log('traffic url was', trafficUrl); + } finally { + await browser.close(); + } +} + +main().catch((err) => { console.error(err); process.exit(1); });