- README: главные возможности, галерея, быстрый старт, ссылки на документы - docs/features.md: гид по всем разделам с новыми скриншотами - docs/configuration.md: переменные окружения, права, роли, планировщик - 26 скриншотов нового дизайна на демо-данных (в т.ч. мобильные) - scripts/seed-demo.ts и scripts/docs-screenshots.mjs для пересъёмки Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
5.4 KiB
JavaScript
148 lines
5.4 KiB
JavaScript
/**
|
|
* Screenshots for README/docs via Chrome DevTools Protocol (no extra deps: Node ≥ 22 has WebSocket).
|
|
* Start the demo first (see seed-demo.ts --serve), then:
|
|
*
|
|
* node apps/api/scripts/docs-screenshots.mjs [outDir=docs/screenshots]
|
|
*
|
|
* Env: BASE (default http://127.0.0.1:3101), CHROME (path to chrome/msedge).
|
|
* Waits for real time (not virtual time) so Recharts animations finish before the shot.
|
|
*/
|
|
import { spawn } from 'node:child_process'
|
|
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
|
|
const outDir = process.argv[2] ?? 'docs/screenshots'
|
|
const BASE = process.env.BASE ?? 'http://127.0.0.1:3101'
|
|
const CHROME =
|
|
process.env.CHROME ??
|
|
[
|
|
'C:/Program Files/Google/Chrome/Application/chrome.exe',
|
|
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
|
'/usr/bin/google-chrome',
|
|
'/usr/bin/chromium',
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
].find(existsSync)
|
|
const PORT = 9333
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
|
const getJson = async (path) => (await fetch(BASE + path)).json()
|
|
const vps = await getJson('/api/vps')
|
|
const projects = await getJson('/api/projects')
|
|
const vpsId = (dns) => vps.find((v) => v.dns === dns)?.id
|
|
const mobile = { width: 390, height: 844, mobile: true }
|
|
|
|
const jobs = [
|
|
{ name: 'dashboard', path: '/dashboard' },
|
|
{ name: 'dashboard-charts', path: '/dashboard', scrollY: 700 },
|
|
{ name: 'vps', path: '/vps' },
|
|
{ name: 'vps-detail', path: `/vps/${vpsId('web-1.example.net')}` },
|
|
{ name: 'vps-detail-finance', path: `/vps/${vpsId('monitor.example.net')}`, clickText: 'Финансы' },
|
|
{ name: 'blocking', path: '/blocking' },
|
|
{ name: 'geo', path: '/geo' },
|
|
{ name: 'topology', path: '/topology', waitMs: 4500 },
|
|
{ name: 'tariffs', path: '/tariffs' },
|
|
{ name: 'providers', path: '/providers' },
|
|
{ name: 'accounts', path: '/accounts' },
|
|
{ name: 'projects', path: '/projects' },
|
|
{ name: 'project-detail', path: `/projects/${projects.find((p) => p.name === 'VPN')?.id}` },
|
|
{ name: 'payments', path: '/payments' },
|
|
{ name: 'balance', path: '/balance' },
|
|
{ name: 'reports', path: '/reports' },
|
|
{ name: 'resources', path: '/resources' },
|
|
{ name: 'renewals', path: '/renewals' },
|
|
{ name: 'settings', path: '/settings' },
|
|
{ name: 'settings-sync', path: '/settings/sync' },
|
|
{ name: 'settings-notifications', path: '/settings/notifications' },
|
|
{ name: 'settings-integrations', path: '/settings/integrations' },
|
|
{ name: 'sync-journal', path: '/sync-journal' },
|
|
{ name: 'audit', path: '/audit' },
|
|
{ name: 'mobile-dashboard', path: '/dashboard', ...mobile },
|
|
{ name: 'mobile-vps', path: '/vps', ...mobile },
|
|
]
|
|
|
|
mkdirSync(outDir, { recursive: true })
|
|
const chrome = spawn(
|
|
CHROME,
|
|
[
|
|
'--headless=new',
|
|
'--disable-gpu',
|
|
'--hide-scrollbars',
|
|
`--remote-debugging-port=${PORT}`,
|
|
`--user-data-dir=${mkdtempSync(join(tmpdir(), 'vt-shots-'))}`,
|
|
'about:blank',
|
|
],
|
|
{ stdio: 'ignore' },
|
|
)
|
|
|
|
let target
|
|
for (let i = 0; i < 50 && !target; i++) {
|
|
await sleep(200)
|
|
try {
|
|
target = (await getJsonAbs(`http://127.0.0.1:${PORT}/json`)).find((t) => t.type === 'page')
|
|
} catch {}
|
|
}
|
|
async function getJsonAbs(url) {
|
|
return (await fetch(url)).json()
|
|
}
|
|
|
|
const ws = new WebSocket(target.webSocketDebuggerUrl)
|
|
await new Promise((r) => ws.addEventListener('open', r))
|
|
let seq = 0
|
|
const pending = new Map()
|
|
ws.addEventListener('message', (e) => {
|
|
const m = JSON.parse(e.data)
|
|
if (m.id && pending.has(m.id)) {
|
|
pending.get(m.id)(m)
|
|
pending.delete(m.id)
|
|
}
|
|
})
|
|
const send = (method, params = {}) =>
|
|
new Promise((r) => {
|
|
const id = ++seq
|
|
pending.set(id, r)
|
|
ws.send(JSON.stringify({ id, method, params }))
|
|
})
|
|
const evaluate = async (expression) =>
|
|
(await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result?.result
|
|
?.value
|
|
|
|
await send('Page.enable')
|
|
await send('Runtime.enable')
|
|
for (const job of jobs) {
|
|
await send('Emulation.setDeviceMetricsOverride', {
|
|
width: job.width ?? 1440,
|
|
height: job.height ?? 900,
|
|
deviceScaleFactor: job.mobile ? 2 : 1,
|
|
mobile: Boolean(job.mobile),
|
|
})
|
|
await send('Page.navigate', { url: BASE + job.path })
|
|
await sleep(job.waitMs ?? 3500)
|
|
if (job.clickText) {
|
|
// Base UI tabs react to real pointer input, so click via CDP at the element's centre.
|
|
for (let i = 0; i < 20 && !(await evaluate(`document.querySelectorAll('[role=tab]').length`)); i++)
|
|
await sleep(500)
|
|
const pos = await evaluate(`(() => {
|
|
const el = [...document.querySelectorAll('button,[role=tab],a')]
|
|
.find((e) => (e.innerText || '').trim().startsWith(${JSON.stringify(job.clickText)}))
|
|
if (!el) return null
|
|
const r = el.getBoundingClientRect()
|
|
return { x: r.x + r.width / 2, y: r.y + r.height / 2 } })()`)
|
|
if (!pos) console.warn(job.name, 'not found:', job.clickText)
|
|
else
|
|
for (const type of ['mousePressed', 'mouseReleased'])
|
|
await send('Input.dispatchMouseEvent', { type, x: pos.x, y: pos.y, button: 'left', clickCount: 1 })
|
|
await sleep(1800)
|
|
}
|
|
if (job.scrollY) {
|
|
await evaluate(`window.scrollBy(0, ${job.scrollY})`)
|
|
await sleep(1200)
|
|
}
|
|
const shot = await send('Page.captureScreenshot', { format: 'png' })
|
|
writeFileSync(join(outDir, `${job.name}.png`), Buffer.from(shot.result.data, 'base64'))
|
|
console.log('ok', job.name)
|
|
}
|
|
ws.close()
|
|
chrome.kill()
|
|
process.exit(0)
|