diff --git a/apps/web/tests/lib/performance-benchmark.test.ts b/apps/web/tests/lib/performance-benchmark.test.ts new file mode 100644 index 00000000..e219702f --- /dev/null +++ b/apps/web/tests/lib/performance-benchmark.test.ts @@ -0,0 +1,95 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + distribution, + measureBundles, + pacificEpoch, + queryScenarios +} from '../performance/benchmark-lib.ts'; + +describe('performance benchmark measurements', () => { + it('uses nearest-rank percentiles and excludes missing browser metrics', () => { + expect(distribution([50, 10, null, 30, 20, 40])).toEqual({ + count: 5, + p50: 30, + p95: 50, + min: 10, + max: 50 + }); + expect(distribution([null])).toEqual({ count: 0, p50: null, p95: null, min: null, max: null }); + }); + it('matches Pacific inclusive UI dates through both DST transitions', () => { + expect(pacificEpoch('2025-06-01')).toBe(Date.parse('2025-06-01T07:00:00Z') / 1000); + expect(pacificEpoch('2025-03-09', true) - pacificEpoch('2025-03-09')).toBe(23 * 3600); + expect(pacificEpoch('2025-11-02', true) - pacificEpoch('2025-11-02')).toBe(25 * 3600); + expect(() => pacificEpoch('2025-02-30')).toThrow(); + }); + it('uses bounded grouped queries, source filtering, and valid locality settings', () => { + const scenarios = queryScenarios( + { datasetId: 'sample', hasLocality: false }, + ['a', 'b'], + '2025-06-01', + '2026-06-30' + ); + expect(scenarios[0].params.routers).toBe('a,b'); + expect(scenarios[1].endDate).toBe('2025-11-27'); + expect(scenarios[2]).toMatchObject({ + endDate: '2025-06-07', + params: { routers: 'a', direction: 'all', groupBy: 'hour', granularity: '1h' } + }); + expect(scenarios[3].params.ipVersion).toBe('6'); + expect( + queryScenarios( + { datasetId: 'sample', hasLocality: true }, + ['a'], + '2025-06-01', + '2026-06-30' + )[2].params.direction + ).toBe('ingress'); + expect(() => queryScenarios({ datasetId: 'sample' }, [], '2025-06-01', '2026-06-30')).toThrow(); + }); +}); + +describe('bundle route accounting', () => { + let directory: string; + afterEach(async () => { + if (directory) await rm(directory, { recursive: true, force: true }); + }); + it('deduplicates shared imports per route and includes boot, layouts, and CSS', async () => { + directory = await mkdtemp(join(tmpdir(), 'atlantis-bundles-')); + const client = join(directory, 'output/client'); + const generated = join(directory, 'generated/build/client-optimized/nodes'); + await mkdir(join(client, '.vite'), { recursive: true }); + await mkdir(generated, { recursive: true }); + const manifest = { + boot: { name: 'entry/start', file: 'boot.js', imports: ['shared'] }, + shared: { name: 'shared', file: 'shared.js' }, + layout: { name: 'nodes/0', file: 'layout.js', imports: ['shared'], css: ['layout.css'] }, + page: { name: 'nodes/2', file: 'page.js', imports: ['shared'] } + }; + await writeFile(join(client, '.vite/manifest.json'), JSON.stringify(manifest)); + await writeFile( + join(generated, '0.js'), + 'export { default as component } from "../../src/routes/+layout.svelte";' + ); + await writeFile( + join(generated, '2.js'), + 'export { default as component } from "../../src/routes/example/+page.svelte";' + ); + for (const file of ['boot.js', 'shared.js', 'layout.js', 'layout.css', 'page.js']) + await writeFile(join(client, file), 'x'.repeat(100)); + const result = await measureBundles(client); + expect(result.total.rawBytes).toBe(500); + expect(result.routes).toHaveLength(1); + expect(result.routes[0]).toMatchObject({ + route: '/example', + rawBytes: 500, + js: { rawBytes: 400 }, + css: { rawBytes: 100 } + }); + expect(result.routes[0].files).toHaveLength(5); + expect(result.total.gzipBytes).toBeLessThan(result.total.rawBytes); + }); +}); diff --git a/apps/web/tests/performance/benchmark-lib.ts b/apps/web/tests/performance/benchmark-lib.ts new file mode 100644 index 00000000..1f0f6bab --- /dev/null +++ b/apps/web/tests/performance/benchmark-lib.ts @@ -0,0 +1,264 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; +import { gzipSync } from 'node:zlib'; + +export function distribution(samples: (number | null | undefined)[]) { + const values = samples + .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) + .sort((a, b) => a - b); + if (values.length === 0) return { count: 0, p50: null, p95: null, min: null, max: null }; + const percentile = (p: number) => values[Math.max(0, Math.ceil(values.length * p) - 1)]; + return { + count: values.length, + p50: percentile(0.5), + p95: percentile(0.95), + min: values[0], + max: values.at(-1) + }; +} + +export function pacificEpoch(date: string, end = false) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(Date.parse(date))) + throw new Error(`Invalid date: ${date}`); + const day = new Date(`${date}T00:00:00Z`); + if (day.toISOString().slice(0, 10) !== date) throw new Error(`Invalid date: ${date}`); + if (end) day.setUTCDate(day.getUTCDate() + 1); + const target = day.getTime(); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Los_Angeles', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23' + }); + let guess = target + 8 * 3_600_000; + for (let i = 0; i < 3; i++) { + const parts = Object.fromEntries( + formatter.formatToParts(guess).map(({ type, value }) => [type, value]) + ); + const wallClock = Date.UTC( + Number(parts.year), + Number(parts.month) - 1, + Number(parts.day), + Number(parts.hour), + Number(parts.minute), + Number(parts.second) + ); + guess += target - wallClock; + } + return guess / 1000; +} + +export function queryScenarios( + dataset: { datasetId: string; hasLocality?: boolean }, + routers: string[], + startDate: string, + endDate: string +) { + if (!routers.length) throw new Error('The dataset has no sources'); + if (pacificEpoch(startDate) >= pacificEpoch(endDate, true)) + throw new Error('Start date must be on or before end date'); + const dateAfter = (days: number) => + new Date(Date.parse(`${startDate}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); + const make = ( + name: string, + end: string, + groupBy: string, + selectedRouters: string[], + direction = 'all', + ipVersion = '4' + ) => ({ + name, + startDate, + endDate: end, + groupBy, + params: { + dataset: dataset.datasetId, + routers: selectedRouters.join(','), + startDate: String(pacificEpoch(startDate)), + endDate: String(pacificEpoch(end, true)), + groupBy, + granularity: groupBy === 'date' ? '1d' : '1h', + direction, + ipVersion, + measure: 'addresses' + } + }); + return [ + make('default', endDate, 'date', routers), + make('long-range', [endDate, dateAfter(179)].sort()[0], 'date', routers), + make( + 'filtered-hourly', + [endDate, dateAfter(6)].sort()[0], + 'hour', + routers.slice(0, 1), + dataset.hasLocality ? 'ingress' : 'all' + ), + make('ipv6-hourly', [endDate, dateAfter(6)].sort()[0], 'hour', routers.slice(0, 1), 'all', '6') + ]; +} + +type BundleFile = { file: string; type: string; rawBytes: number; gzipBytes: number }; +type ManifestChunk = { name: string; file: string; css?: string[]; imports?: string[] }; + +export async function measureBundles(directory: string) { + const manifest: Record = JSON.parse( + await readFile(join(directory, '.vite/manifest.json'), 'utf8') + ); + const files: BundleFile[] = []; + async function walk(path: string) { + for (const entry of await readdir(path, { withFileTypes: true })) { + const full = join(path, entry.name); + if (entry.isDirectory()) await walk(full); + else if (/\.(js|css)$/.test(entry.name)) { + const contents = await readFile(full); + files.push({ + file: relative(directory, full), + type: entry.name.endsWith('.css') ? 'css' : 'js', + rawBytes: contents.length, + gzipBytes: gzipSync(contents, { level: 9 }).length + }); + } + } + } + await walk(directory); + const sum = (selected: BundleFile[]) => ({ + rawBytes: selected.reduce((sum, file) => sum + file.rawBytes, 0), + gzipBytes: selected.reduce((sum, file) => sum + file.gzipBytes, 0) + }); + function closure(keys: string[]) { + const seen = new Set(); + const names = new Set(); + function visit(key: string) { + if (seen.has(key)) return; + seen.add(key); + const chunk = manifest[key]; + if (!chunk) throw new Error(`Manifest dependency is missing: ${key}`); + names.add(chunk.file); + for (const css of chunk.css ?? []) names.add(css); + for (const dependency of chunk.imports ?? []) visit(dependency); + } + keys.forEach(visit); + return files.filter(({ file }) => names.has(file)); + } + const boot = Object.keys(manifest).filter( + (key) => /^entry\//.test(manifest[key].name) || manifest[key].name === 'client-entry' + ); + const nodes: { key: string; route: string; kind: string }[] = []; + for (const [key, chunk] of Object.entries(manifest)) { + if (!/^nodes\//.test(chunk.name)) continue; + const source = await readFile( + join(resolve(directory, '../../generated/build/client-optimized'), `${chunk.name}.js`), + 'utf8' + ); + const component = /src\/routes\/(.*?)\+(page|layout)\.svelte/.exec(source); + if (component) + nodes.push({ key, route: `/${component[1]}`.replace(/\/$/, '') || '/', kind: component[2] }); + } + const routes = nodes + .filter(({ kind }) => kind === 'page') + .map(({ key, route }) => { + const layouts = nodes + .filter( + (node) => + node.kind === 'layout' && + (node.route === '/' || route === node.route || route.startsWith(`${node.route}/`)) + ) + .map(({ key }) => key); + const selected = closure([...boot, ...layouts, key]); + return { + route, + entry: manifest[key].file, + ...sum(selected), + js: sum(selected.filter(({ type }) => type === 'js')), + css: sum(selected.filter(({ type }) => type === 'css')), + files: selected.map(({ file }) => file) + }; + }); + if (!routes.length) throw new Error('No route entries found in the production manifest'); + return { + total: sum(files), + js: sum(files.filter(({ type }) => type === 'js')), + css: sum(files.filter(({ type }) => type === 'css')), + files: files.sort((a, b) => b.rawBytes - a.rawBytes), + routes + }; +} + +type Distribution = ReturnType; +type SummaryReport = { + createdAt: string; + baseUrl: string; + environment: { commit: string }; + dataset: { datasetId: string }; + scenarios: ReturnType; + options: { warmups: number; runs: number; pageRuns: number; cpuSlowdown: number }; + bundles: Awaited>; + api: { + scenario: string; + route: string; + latencyMs: Distribution; + samples: { decodedBytes: number }[]; + error: string | null; + }[]; + pages: { name: string; summary: Record }[]; + errors: string[]; +}; + +export function markdownSummary(report: SummaryReport) { + const ms = (value: number | null | undefined) => + value === null || value === undefined ? 'n/a' : value.toFixed(1); + const kib = (value: number) => (value / 1024).toFixed(1); + return [ + '# Dashboard performance baseline', + '', + `Captured ${report.createdAt} at ${report.baseUrl}. Commit ${report.environment.commit}.`, + '', + `Dataset ${report.dataset.datasetId}; ${report.scenarios[0].startDate} through ${report.scenarios[0].endDate}, inclusive Pacific dates. Sequential API requests, ${report.options.warmups} discarded warmups and ${report.options.runs} measured runs per query. Fresh browser contexts, ${report.options.pageRuns} runs per page, viewport 1280 x 800, CPU slowdown ${report.options.cpuSlowdown}x.`, + '', + '## API latency', + '', + '| Scenario | Route | p50 ms | p95 ms | Body KiB | Error |', + '| --- | --- | ---: | ---: | ---: | --- |', + ...report.api.map( + (item) => + `| ${item.scenario} | ${item.route} | ${ms(item.latencyMs.p50)} | ${ms(item.latencyMs.p95)} | ${item.samples.length ? kib(item.samples[0].decodedBytes) : 'n/a'} | ${item.error ?? ''} |` + ), + '', + '## Production bundles', + '', + `All JS: ${kib(report.bundles.js.rawBytes)} KiB raw / ${kib(report.bundles.js.gzipBytes)} KiB gzip. All CSS: ${kib(report.bundles.css.rawBytes)} KiB raw / ${kib(report.bundles.css.gzipBytes)} KiB gzip. Gzip level 9, each file compressed separately.`, + '', + 'Route totals include static imports, root layout, and boot entries, deduplicated within each route. Shared chunks count once per route; route totals must not be added together. Dynamically loaded chunks appear in the file inventory and browser resources.', + '', + '| Route | Entry chunk | Raw KiB | Gzip KiB |', + '| --- | --- | ---: | ---: |', + ...report.bundles.routes.map( + (route) => + `| ${route.route} | ${route.entry} | ${kib(route.rawBytes)} | ${kib(route.gzipBytes)} |` + ), + '', + '| Largest file | Raw KiB | Gzip KiB |', + '| --- | ---: | ---: |', + ...report.bundles.files + .slice(0, 10) + .map((file) => `| ${file.file} | ${kib(file.rawBytes)} | ${kib(file.gzipBytes)} |`), + '', + '## Browser loads', + '', + '| Page | TTFB p50 | FCP p50 | LCP p50 | Visible charts p50 | All charts p50 | Initial TBT p50 | All-chart heap MiB p50 |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + ...report.pages.map( + (page) => + `| ${page.name} | ${ms(page.summary.ttfbMs.p50)} | ${ms(page.summary.fcpMs.p50)} | ${ms(page.summary.lcpMs.p50)} | ${ms(page.summary.visibleChartsMs.p50)} | ${ms(page.summary.allChartsMs.p50)} | ${ms(page.summary.tbtMs.p50)} | ${ms(page.summary.heapUsedBytes.p50 === null ? null : page.summary.heapUsedBytes.p50 / 1024 ** 2)} |` + ), + '', + 'LCP is the last observed candidate before scrolling. Initial TBT sums the portion over 50 ms of long tasks between FCP and visible-chart readiness plus the settling interval. It is an observation-window metric, not Lighthouse TBT. JSON also contains post-scroll long tasks, p95 values, per-card readiness, API/resource timings, and heap before/after explicit GC. Charts are ready when their loading text disappears, a chart graphic exists, and two animation frames pass. This excludes deferred cards from initial readiness.', + '', + `Status: ${report.errors.length ? 'FAILED' : 'passed'}. ${report.errors.join('; ')}`, + '' + ].join('\n'); +} diff --git a/apps/web/tests/performance/benchmark.mjs b/apps/web/tests/performance/benchmark.mjs new file mode 100644 index 00000000..edd1cc55 --- /dev/null +++ b/apps/web/tests/performance/benchmark.mjs @@ -0,0 +1,412 @@ +import { chromium } from '@playwright/test'; +import { execFileSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { cpus, hostname, platform, release } from 'node:os'; +import { dirname, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { distribution, markdownSummary, measureBundles, queryScenarios } from './benchmark-lib.ts'; + +const { values } = parseArgs({ + options: { + 'base-url': { + type: 'string', + default: process.env.ATLANTIS_PERF_BASE_URL ?? 'http://127.0.0.1:8090' + }, + dataset: { type: 'string' }, + 'start-date': { type: 'string' }, + 'end-date': { type: 'string' }, + 'file-slug': { type: 'string' }, + output: { type: 'string', default: '/tmp/atlantis-perf/baseline' }, + 'bundle-dir': { type: 'string', default: 'apps/web/.svelte-kit/output/client' }, + runs: { type: 'string', default: '5' }, + 'page-runs': { type: 'string', default: '3' }, + warmups: { type: 'string', default: '1' }, + 'timeout-ms': { type: 'string', default: '120000' }, + 'settle-ms': { type: 'string', default: '1000' }, + 'cpu-slowdown': { type: 'string', default: '1' }, + 'chart-selector': { + type: 'string', + default: 'canvas, svg[role="img"], [data-chart-rendered="true"]' + }, + help: { type: 'boolean', default: false } + } +}); + +if (values.help) { + console.log( + 'bun run perf --base-url URL [--dataset ID] [--start-date YYYY-MM-DD] [--end-date YYYY-MM-DD] [--output /tmp/prefix] [--runs 5] [--page-runs 3] [--warmups 1] [--bundle-dir apps/web/.svelte-kit/output/client] [--cpu-slowdown 1] [--chart-selector CSS]' + ); + process.exit(0); +} +const options = Object.fromEntries( + ['runs', 'page-runs', 'warmups', 'timeout-ms', 'settle-ms', 'cpu-slowdown'].map((key) => [ + key.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), + Number(values[key]) + ]) +); +for (const [key, value] of Object.entries(options)) { + if (!Number.isInteger(value) || value < (key === 'warmups' || key === 'settleMs' ? 0 : 1)) + throw new Error(`Invalid ${key}`); +} +const baseUrl = new URL(values['base-url']).origin; +async function fetchJson(path) { + const started = performance.now(); + const response = await fetch(new URL(path, baseUrl), { + signal: AbortSignal.timeout(options.timeoutMs) + }); + const body = await response.arrayBuffer(); + const latencyMs = performance.now() - started; + if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`); + const payload = JSON.parse(new TextDecoder().decode(body)); + if (payload.error) throw new Error(`${path}: ${payload.error}`); + return { payload, latencyMs, decodedBytes: body.byteLength, status: response.status }; +} +const datasets = (await fetchJson('/api/datasets')).payload.data; +const dataset = values.dataset + ? datasets?.find((item) => item.datasetId === values.dataset) + : (datasets?.find((item) => item.isDefault) ?? datasets?.[0]); +if (!dataset) throw new Error('Requested dataset was not returned by /api/datasets'); +const routers = (await fetchJson(`/api/routers?dataset=${encodeURIComponent(dataset.datasetId)}`)) + .payload; +const maad = ( + await fetchJson(`/api/netflow/maad-status?dataset=${encodeURIComponent(dataset.datasetId)}`) +).payload; +const scenarios = queryScenarios( + dataset, + routers, + values['start-date'] ?? dataset.defaultStartDate, + values['end-date'] ?? new Date().toISOString().slice(0, 10) +); +const report = { + schemaVersion: 1, + createdAt: new Date().toISOString(), + baseUrl, + dataset, + routers, + maad, + options, + scenarios, + environment: { + commit: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), + dirty: Boolean(execFileSync('git', ['status', '--porcelain'], { encoding: 'utf8' }).trim()), + node: process.version, + hostname: hostname(), + platform: platform(), + kernel: release(), + cpu: cpus()[0]?.model, + logicalCpus: cpus().length, + browser: null, + network: 'unthrottled loopback or supplied base URL; server and OS caches are warm' + }, + bundles: await measureBundles(resolve(values['bundle-dir'])), + api: [], + pages: [], + errors: [] +}; +const routes = [ + '/api/netflow/stats', + '/api/netflow/characteristics', + '/api/ip/stats', + '/api/protocol/stats', + '/api/netflow/coverage', + ...(maad.computed + ? [ + '/api/netflow/dimension-stats', + '/api/netflow/spectrum-stats', + '/api/netflow/structure-stats' + ] + : []) +]; +for (const scenario of [ + { + name: 'metadata', + params: { dataset: dataset.datasetId }, + routes: ['/api/datasets', '/api/routers', '/api/netflow/maad-status'] + }, + ...scenarios +]) { + for (const route of scenario.routes ?? routes) { + console.log(`API ${scenario.name} ${route}`); + const path = `${route}?${new URLSearchParams(scenario.params)}`; + const samples = []; + let error = null; + try { + for (let run = -options.warmups; run < options.runs; run++) { + const { payload, ...sample } = await fetchJson(path); + if ( + scenario.name !== 'metadata' && + !payload.result && + !payload.timelines && + !payload.observationBuckets + ) + throw new Error('Unexpected aggregate response shape'); + if (run >= 0) samples.push(sample); + } + } catch (reason) { + error = String(reason); + report.errors.push(error); + } + report.api.push({ + scenario: scenario.name, + route, + params: scenario.params, + samples, + latencyMs: distribution(samples.map(({ latencyMs }) => latencyMs)), + error + }); + } +} + +const loadingCopy = [ + 'Loading data', + 'Loading flow characteristics', + 'Loading IP data', + 'Loading protocol data', + 'Loading spectrum data', + 'Loading coverage', + 'Loading dimension data', + 'Loading port' +]; +async function waitForCharts(page, ids) { + await page.waitForFunction( + ({ ids, selector, loadingCopy }) => + ids.every((id) => { + const card = document.querySelector(`[data-chart-id="${id}"]`); + return ( + card?.getAttribute('data-chart-activated') === 'true' && + loadingCopy.every((copy) => !card.textContent.includes(copy)) && + (card.querySelector(selector) || + card.querySelector( + '[data-testid="chart-unavailable"], [data-testid="chart-selection-unavailable"]' + )) + ); + }), + { ids, selector: values['chart-selector'], loadingCopy }, + { timeout: options.timeoutMs } + ); + return page.evaluate(async () => { + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return performance.now(); + }); +} +async function capture(page, client, cutoff, initial) { + const metrics = await page.evaluate( + ({ cutoff, initial }) => { + const navigation = performance.getEntriesByType('navigation')[0]; + const fcp = performance.getEntriesByName('first-contentful-paint')[0]?.startTime ?? null; + const tasks = window.__atlantisPerf.tasks.filter((task) => task.startTime < cutoff); + const initialTasks = tasks.filter( + (task) => fcp !== null && task.startTime + task.duration > fcp + ); + return { + ttfbMs: navigation.responseStart - navigation.startTime, + fcpMs: fcp, + lcpMs: window.__atlantisPerf.lcp, + domContentLoadedMs: navigation.domContentLoadedEventEnd, + tbtMs: initialTasks.reduce( + (sum, task) => + sum + + Math.max( + 0, + Math.min(task.startTime + task.duration, cutoff) - Math.max(task.startTime, fcp) - 50 + ), + 0 + ), + longTaskCount: tasks.length, + longTaskMs: tasks.reduce((sum, task) => sum + task.duration, 0), + longTasks: tasks, + resources: performance.getEntriesByType('resource').map((entry) => ({ + path: new URL(entry.name).pathname, + durationMs: entry.duration, + transferBytes: entry.transferSize, + decodedBytes: entry.decodedBodySize, + initiatorType: entry.initiatorType + })), + canvasCount: document.querySelectorAll('canvas').length, + observedUntilMs: cutoff, + phase: initial ? 'initial' : 'all-charts' + }; + }, + { cutoff, initial } + ); + const metricsByName = async () => + Object.fromEntries( + (await client.send('Performance.getMetrics')).metrics.map(({ name, value }) => [name, value]) + ); + const before = await metricsByName(); + await client.send('HeapProfiler.collectGarbage'); + const after = await metricsByName(); + return { + ...metrics, + heapUsedBytes: before.JSHeapUsedSize, + heapAfterGcBytes: after.JSHeapUsedSize, + scriptDurationMs: before.ScriptDuration * 1000, + layoutDurationMs: before.LayoutDuration * 1000, + taskDurationMs: before.TaskDuration * 1000 + }; +} + +const dashboardUrl = (scenario) => + `/datasets/${encodeURIComponent(dataset.datasetId)}?${new URLSearchParams({ startDate: scenario.startDate, endDate: scenario.endDate, groupBy: scenario.groupBy, direction: scenario.params.direction, ipVersion: scenario.params.ipVersion })}`; +const pageCases = [ + { name: 'datasets', path: '/', charted: false, ready: 'main h1' }, + { name: 'dashboard-default', path: dashboardUrl(scenarios[0]), charted: true }, + { name: 'dashboard-long-range', path: dashboardUrl(scenarios[1]), charted: true }, + { name: 'dashboard-filtered-hourly', path: dashboardUrl(scenarios[2]), charted: true }, + { + name: 'files', + path: `/netflow/files?dataset=${encodeURIComponent(dataset.datasetId)}`, + charted: false, + ready: '#timestamp' + } +]; +if (maad.computed) + pageCases.push({ + name: 'file-detail', + path: `/netflow/files/${values['file-slug'] ?? `${scenarios[0].startDate.replaceAll('-', '')}0000`}?dataset=${encodeURIComponent(dataset.datasetId)}`, + charted: false, + fileDetail: true + }); +let browser; +try { + browser = await chromium.launch({ headless: true }); + report.environment.browser = browser.version(); + for (const pageCase of pageCases) { + const samples = []; + for (let run = 0; run < options.pageRuns; run++) { + console.log(`PAGE ${pageCase.name} ${run + 1}/${options.pageRuns}`); + const context = await browser.newContext({ + viewport: { width: 1280, height: 800 }, + timezoneId: 'America/Los_Angeles', + reducedMotion: 'reduce' + }); + try { + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + page.on('response', (response) => { + if (response.status() >= 400) + errors.push(`HTTP ${response.status()} ${new URL(response.url()).pathname}`); + }); + page.on('requestfailed', (request) => { + if (request.failure()?.errorText !== 'net::ERR_ABORTED') + errors.push(`${new URL(request.url()).pathname}: ${request.failure()?.errorText}`); + }); + await context.addInitScript(() => { + window.__atlantisPerf = { lcp: null, tasks: [] }; + new PerformanceObserver((list) => { + window.__atlantisPerf.lcp = list.getEntries().at(-1)?.startTime ?? null; + }).observe({ type: 'largest-contentful-paint', buffered: true }); + new PerformanceObserver((list) => { + window.__atlantisPerf.tasks.push( + ...list.getEntries().map(({ startTime, duration }) => ({ startTime, duration })) + ); + }).observe({ type: 'longtask', buffered: true }); + }); + const client = await context.newCDPSession(page); + await client.send('Performance.enable'); + await client.send('Emulation.setCPUThrottlingRate', { rate: options.cpuSlowdown }); + const navigation = await page.goto(new URL(pageCase.path, baseUrl).href, { + waitUntil: 'load', + timeout: options.timeoutMs + }); + if (!navigation?.ok()) throw new Error(`Page returned ${navigation?.status()}`); + let visibleChartsMs = null; + let allChartsMs = null; + const cardReadiness = []; + if (pageCase.charted) { + await page.waitForSelector('[data-chart-activated="true"]', { + timeout: options.timeoutMs + }); + const initialIds = await page + .locator('[data-chart-activated="true"]') + .evaluateAll((cards) => cards.map((card) => card.getAttribute('data-chart-id'))); + visibleChartsMs = await waitForCharts(page, initialIds); + } else if (pageCase.fileDetail) { + await page.waitForFunction( + (selector) => + document.querySelector(selector) && + !/Loading|Refreshing/.test(document.body.textContent), + values['chart-selector'], + { timeout: options.timeoutMs } + ); + visibleChartsMs = await page.evaluate(async () => { + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ); + return performance.now(); + }); + allChartsMs = visibleChartsMs; + } else await page.locator(pageCase.ready).first().waitFor(); + await page.waitForTimeout(options.settleMs); + const initial = await capture( + page, + client, + await page.evaluate(() => performance.now()), + true + ); + if (pageCase.charted) { + const ids = await page + .locator('[data-chart-id]') + .evaluateAll((cards) => cards.map((card) => card.getAttribute('data-chart-id'))); + for (const id of ids) { + const card = page.locator(`[data-chart-id="${id}"]`); + if ((await card.getAttribute('data-chart-activated')) !== 'true') + await page.locator(`[data-chart-sentinel="${id}"]`).scrollIntoViewIfNeeded(); + cardReadiness.push({ id, readyMs: await waitForCharts(page, [id]) }); + } + allChartsMs = await waitForCharts(page, ids); + await page.waitForTimeout(options.settleMs); + } + const all = pageCase.charted + ? await capture(page, client, await page.evaluate(() => performance.now()), false) + : initial; + const localFiles = new Set(report.bundles.files.map(({ file }) => `/${file}`)); + for (const resource of all.resources) + if (/\.(js|css)$/.test(resource.path) && !localFiles.has(resource.path)) + errors.push(`Served asset is absent from the measured build: ${resource.path}`); + samples.push({ ...initial, visibleChartsMs, allChartsMs, cardReadiness, all, errors }); + report.errors.push(...errors.map((error) => `${pageCase.name}: ${error}`)); + } catch (error) { + report.errors.push(`${pageCase.name} run ${run + 1}: ${error}`); + } finally { + await context.close(); + } + } + const metrics = [ + 'ttfbMs', + 'fcpMs', + 'lcpMs', + 'visibleChartsMs', + 'allChartsMs', + 'tbtMs', + 'heapUsedBytes', + 'heapAfterGcBytes', + 'longTaskMs', + 'longTaskCount' + ]; + report.pages.push({ + ...pageCase, + samples, + summary: Object.fromEntries( + metrics.map((metric) => [ + metric, + distribution( + samples.map((sample) => + metric.startsWith('heap') ? sample.all[metric] : sample[metric] + ) + ) + ]) + ) + }); + } +} catch (error) { + report.errors.push(String(error)); +} finally { + await browser?.close(); +} +await mkdir(dirname(resolve(values.output)), { recursive: true }); +await writeFile(`${values.output}.json`, `${JSON.stringify(report, null, 2)}\n`); +await writeFile(`${values.output}.md`, markdownSummary(report)); +console.log(`Saved ${values.output}.{json,md}; ${report.errors.length} errors`); +if (report.errors.length) process.exitCode = 1; diff --git a/docs/code/README.md b/docs/code/README.md index 661a1b1b..4264b7bd 100644 --- a/docs/code/README.md +++ b/docs/code/README.md @@ -8,6 +8,7 @@ These documents explain how ATLANTIS is built and how to change it. 2. Complete the [development setup](development.md). 3. Read the [pipeline contract](pipeline-contract.md) before you change pipeline semantics. 4. Read [MAAD conformance](maad-conformance.md) before you change the MAAD estimator. +5. Use [Dashboard performance](performance.md) to deploy an isolated benchmark stage and compare production measurements. ## Documentation maintenance diff --git a/docs/code/performance.md b/docs/code/performance.md new file mode 100644 index 00000000..ebb03798 --- /dev/null +++ b/docs/code/performance.md @@ -0,0 +1,69 @@ +# Dashboard performance + +Measure a production SQLite deployment with `bun run perf`. The command reads HTTP APIs and local build artifacts. It does not open or modify a database. + +## Create an isolated stage + +Use the self-hosted stack with a data directory containing `/netflow.sqlite`. Keep host paths in an external shell file, outside the checkout. Export these settings before deploying: + +```bash +export ATLANTIS_SELF_HOSTED_DOCKER_HOST=ssh://user@server.example.com +export ATLANTIS_SELF_HOSTED_DATA_DIR=/path/to/products +export ATLANTIS_SELF_HOSTED_DATA_USER=1000:1000 +export ATLANTIS_SELF_HOSTED_STATE_DIR=/srv/atlantis-perf +export ATLANTIS_SELF_HOSTED_PORT=8090 +export ATLANTIS_SELF_HOSTED_READ_ONLY=true +bun run plan:self-hosted --stage perf --no-input +bun run deploy:self-hosted --stage perf --yes --no-input +``` + +The `perf` stage uses the Docker context `atlantis-perf`, container `atlantis-perf-web`, and port `8090` by default. It requires read-only data, rejects port `8080`, and requires a state directory outside the checkout. Docker mounts the data directory read-only, and the SQLite driver also opens products read-only with `query_only` enabled. Completed SQLite products without a live WAL can be served this way. No copy, hardlink, or database change is needed. + +The launcher starts Alchemy in `ATLANTIS_SELF_HOSTED_STATE_DIR`, so state and logs live under its `.alchemy/` directory. The build context still comes from the checkout. Reuse the same state directory and settings to redeploy or destroy that stage. Other stages preserve their existing names, port defaults, writable mounts, and checkout state default. + +Reach a remote stage through its printed SSH tunnel. Verify `/api/datasets` and a chart before benchmarking. Keep the private product and reports out of commits. + +## Record a baseline + +Build from the same checkout as the deployment. Install the Chromium version pinned by Playwright once: + +```bash +bun install --frozen-lockfile +bunx playwright install chromium +ATLANTIS_DB_DRIVER=sqlite bun run build:web +bun run perf --base-url http://127.0.0.1:8090 \ + --dataset sample --end-date 2026-06-30 \ + --output /tmp/atlantis-perf/baseline +``` + +The script discovers the selected dataset and sources over HTTP. `--start-date` defaults to the dataset's default date, and `--end-date` defaults to today's UTC date. Dates have the dashboard's inclusive Pacific-day semantics; API timestamps use half-open intervals. Pin both dates when comparing changes. The six-month query starts at the same date, and the hourly filtered and IPv6 variants cover up to seven days. A dataset without locality uses the all-directions filter. + +Each metadata or aggregate API query gets one discarded warmup and five sequential measured requests. The aggregate routes cover traffic, characteristics, IP counts, protocol counts, coverage, and, when computed, MAAD dimensions, spectrum, and structure. API latency includes receipt of the full response body. These are warm-server measurements, without concurrency or OS-cache eviction. + +Each page gets three fresh Chromium contexts, with a 1280 x 800 viewport, Pacific timezone, reduced motion, and no network or CPU throttling. Pages cover dataset selection, dashboard default and six-month ranges, an hourly filtered dashboard, file navigation, and a file detail when MAAD exists. `--file-slug` selects a populated five-minute bucket; the default is midnight on the start date. Use `--cpu-slowdown 4` for an additional CPU-throttled comparison, with a separate output prefix. + +The reports contain TTFB, FCP, LCP, chart readiness, long tasks, observed TBT, resource timings, and JS heap before and after explicit GC. LCP uses the last candidate before scrolling. Initial readiness excludes deferred chart cards. The script scrolls to each deferred card and records all-chart readiness and heap. + +TBT sums task time beyond 50 ms after FCP through the initial settling interval. It is not Lighthouse's interactive-window TBT. Chromium's DevTools protocol reports heap usage. GC between phases affects all-chart timings and remains constant across runs. + +Chart readiness requires the loading text to disappear, a chart graphic to exist, and two animation frames to pass. It does not wait for every animation to finish. The default graphic selector supports canvas, accessible SVGs, and `data-chart-rendered="true"`. Use `--chart-selector` if a chart migration changes that contract. Unavailable MAAD cards may settle without a graphic. + +Bundle sizes come from the Vite client manifest and SvelteKit's generated route nodes in `apps/web/.svelte-kit`. The JSON inventory includes every JS/CSS file, raw and gzip level 9, plus deduplicated static imports, boot code, and layouts for each route. Shared files count once within a route; route totals overlap. Dynamically imported files also appear in the inventory and observed browser resources. A served JS/CSS filename absent from the local build fails the run, so rebuild both deployment and local artifacts after a UI change. + +For Docker deployments, export the production build instead of measuring a local build: + +```bash +bun run perf:bundle /tmp/atlantis-perf/build +bun run perf --base-url http://127.0.0.1:8090 \ + --dataset sample --end-date 2026-06-30 \ + --bundle-dir /tmp/atlantis-perf/build/.svelte-kit/output/client \ + --output /tmp/atlantis-perf/baseline +``` + +The export command rebuilds the Dockerfile's `build` target and copies its artifacts through a temporary container. Docker reuses cached layers. The command removes only that temporary container. `ATLANTIS_PERF_DOCKER_CONTEXT` defaults to `atlantis-perf`. Local builds can contain extra Tailwind classes from test files that Docker excludes. + +`--runs`, `--page-runs`, `--warmups`, `--timeout-ms`, `--settle-ms`, and `--bundle-dir` are configurable. p50/p95 use nearest-rank percentiles. With five API samples and three page samples, p95 is the slowest observed run. Increase sample counts for a more stable comparison. + +JSON retains individual samples and errors. HTTP, browser, missing-chart, or build-mismatch errors cause a nonzero exit code and a failed report. Setup errors fail before a report is written. + +The command writes `.json` and `.md`. Keep them outside the repository. Use the same machine, dataset, dates, sample counts, browser, and deployment limits for later layers, and avoid overlapping builds or tests with a baseline run. diff --git a/docs/user/operations.md b/docs/user/operations.md index 3858ef6f..afd1be44 100644 --- a/docs/user/operations.md +++ b/docs/user/operations.md @@ -61,6 +61,10 @@ Optional variables: - `ATLANTIS_SELF_HOSTED_PORT`: server loopback port, default `8080`. - `ATLANTIS_SELF_HOSTED_DATA_USER`: `:` that owns the data files, default `1000:1000`. Get it with `id -u` and `id -g` on the server. +- `ATLANTIS_SELF_HOSTED_READ_ONLY`: mount the data directory read-only, default `false`. Serve completed products without a live WAL when enabling this. +- `ATLANTIS_SELF_HOSTED_STATE_DIR`: absolute directory for Alchemy state and logs, default the checkout. Reuse it for every plan, deploy, and destroy of the stage. + +The isolated `perf` stage defaults to port `8090` and read-only data. It requires an external state directory. See [Dashboard performance](../code/performance.md) for setup and benchmark commands. The deploy asks for approval (`--yes` skips it) and prints the tunnel command. Deploy again after pulling new code. An unchanged checkout leaves the container alone. `bun run plan:self-hosted --stage prod` previews the change. diff --git a/infra/run-self-hosted.ts b/infra/run-self-hosted.ts new file mode 100644 index 00000000..90656afe --- /dev/null +++ b/infra/run-self-hosted.ts @@ -0,0 +1,27 @@ +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { selfHostedRepoRoot, selfHostedStateDirectory } from './self-hosted-config.ts'; + +const [action, ...args] = process.argv.slice(2); +if (!['deploy', 'plan', 'destroy'].includes(action)) + throw new Error('Expected deploy, plan, or destroy'); +const stageIndex = args.indexOf('--stage'); +const stage = + stageIndex >= 0 + ? args[stageIndex + 1] + : (args.find((arg) => arg.startsWith('--stage='))?.slice(8) ?? + process.env.ALCHEMY_STAGE ?? + 'prod'); +const directory = selfHostedStateDirectory(process.env.ATLANTIS_SELF_HOSTED_STATE_DIR, stage); +await mkdir(directory, { recursive: true }); +process.chdir(directory); +process.argv = [ + process.argv[0], + process.argv[1], + action, + resolve(selfHostedRepoRoot, 'infra/self-hosted.ts'), + ...args +]; +const { main } = await import('alchemy/Cli/main'); +const { runMain } = await import('alchemy/Util/PlatformServices'); +main.pipe(runMain); diff --git a/infra/self-hosted-config.ts b/infra/self-hosted-config.ts new file mode 100644 index 00000000..ad03243f --- /dev/null +++ b/infra/self-hosted-config.ts @@ -0,0 +1,46 @@ +import { isAbsolute, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { appName, stageName } from './shared.ts'; + +export const selfHostedRepoRoot = fileURLToPath(new URL('../', import.meta.url)); + +export function selfHostedStateDirectory(value: string | undefined, stage: string) { + if (!value) { + if (stage === 'perf') throw new Error('The perf stage requires ATLANTIS_SELF_HOSTED_STATE_DIR'); + return selfHostedRepoRoot; + } + if (!isAbsolute(value) || resolve(value) === '/') { + throw new Error('ATLANTIS_SELF_HOSTED_STATE_DIR must be an absolute non-root directory'); + } + const directory = resolve(value); + const fromRepo = relative(selfHostedRepoRoot, directory); + if ( + stage === 'perf' && + (fromRepo === '' || (!fromRepo.startsWith('../') && !isAbsolute(fromRepo))) + ) { + throw new Error('The perf stage state must be outside the repository'); + } + return directory; +} + +export function selfHostedContainerSettings( + stage: string, + dataDir: string, + port: number, + readOnly: boolean +) { + if (!isAbsolute(dataDir) || resolve(dataDir) === '/') { + throw new Error('ATLANTIS_SELF_HOSTED_DATA_DIR must be an absolute non-root directory'); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('ATLANTIS_SELF_HOSTED_PORT must be a valid TCP port'); + } + if (stage === 'perf' && (!readOnly || port === 8080)) { + throw new Error('The perf stage requires read-only data and a port other than 8080'); + } + return { + name: stage === 'perf' ? `${appName}-perf-web` : stageName(`${appName}-self-hosted-web`, stage), + volumes: [{ hostPath: dataDir, containerPath: '/data', readOnly }], + ports: [{ external: `127.0.0.1:${port}`, internal: 3000 }] + }; +} diff --git a/infra/self-hosted.ts b/infra/self-hosted.ts index 6b1d73f9..50f25197 100644 --- a/infra/self-hosted.ts +++ b/infra/self-hosted.ts @@ -4,7 +4,12 @@ import * as Config from 'effect/Config'; import * as ConfigProvider from 'effect/ConfigProvider'; import * as Effect from 'effect/Effect'; import { hashAllowlistedBuildContext } from './build-context.ts'; -import { appName, invalidStageMessage, repoRoot, stageName, webDockerfile } from './shared.ts'; +import { appName, invalidStageMessage, stageName, webDockerfile } from './shared.ts'; +import { + selfHostedContainerSettings, + selfHostedRepoRoot, + selfHostedStateDirectory +} from './self-hosted-config.ts'; import { parseSshDockerHost, sshTunnelCommand } from './ssh.ts'; const selfHostedName = `${appName}-self-hosted`; @@ -42,31 +47,42 @@ export default Alchemy.Stack( Config.map(parseSshDockerHost) ); const dataDir = yield* Config.String('ATLANTIS_SELF_HOSTED_DATA_DIR'); - const port = yield* Config.Port('ATLANTIS_SELF_HOSTED_PORT').pipe(Config.withDefault(8080)); + const port = yield* Config.Port('ATLANTIS_SELF_HOSTED_PORT').pipe( + Config.withDefault(stage === 'perf' ? 8090 : 8080) + ); + const readOnly = yield* Config.Boolean('ATLANTIS_SELF_HOSTED_READ_ONLY').pipe( + Config.withDefault(stage === 'perf') + ); const dataUser = yield* Config.String('ATLANTIS_SELF_HOSTED_DATA_USER').pipe( Config.withDefault('1000:1000'), Config.map(parseDataUser) ); - if (!dataDir.startsWith('/') || dataDir === '/') { - return yield* Effect.die( - new Error( - `ATLANTIS_SELF_HOSTED_DATA_DIR must be an absolute host directory, got '${dataDir}'` - ) + if (stage === 'perf') { + const stateDirectory = selfHostedStateDirectory( + process.env.ATLANTIS_SELF_HOSTED_STATE_DIR, + stage ); + if (process.cwd() !== stateDirectory) + return yield* Effect.die( + new Error( + 'Deploy perf through bun run deploy:self-hosted so Alchemy uses the external state directory' + ) + ); } - const webName = stageName(`${selfHostedName}-web`, stage); + const settings = selfHostedContainerSettings(stage, dataDir, port, readOnly); + const webName = settings.name; const buildArgs = { RUNTIME_UID: dataUser.uid, RUNTIME_GID: dataUser.gid }; const buildHash = yield* hashAllowlistedBuildContext({ - context: repoRoot, + context: selfHostedRepoRoot, dockerfile: webDockerfile, platform, buildArgs }).pipe(Effect.orDie); const context = yield* Docker.Context('DockerHost', { - name: stageName(selfHostedName, stage), + name: stage === 'perf' ? `${appName}-perf` : stageName(selfHostedName, stage), docker: `host=${dockerHost.url}`, description: `ATLANTIS self-hosted deployment (${stage})` }); @@ -76,7 +92,7 @@ export default Alchemy.Stack( tag: buildHash, context, build: { - context: repoRoot, + context: selfHostedRepoRoot, dockerfile: webDockerfile, platform, args: buildArgs @@ -90,8 +106,8 @@ export default Alchemy.Stack( environment: { NODE_OPTIONS: '--max-old-space-size=768' }, - volumes: [{ hostPath: dataDir, containerPath: '/data' }], - ports: [{ external: `127.0.0.1:${port}`, internal: webPort }], + volumes: settings.volumes, + ports: settings.ports, memory: '1g', restart: 'unless-stopped', stopTimeout: '30 seconds', diff --git a/infra/tests/self-hosted.test.ts b/infra/tests/self-hosted.test.ts new file mode 100644 index 00000000..6c8fc363 --- /dev/null +++ b/infra/tests/self-hosted.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + selfHostedContainerSettings, + selfHostedRepoRoot, + selfHostedStateDirectory +} from '../self-hosted-config.ts'; + +describe('self-hosted deployment isolation', () => { + it('preserves the test stage port, name, and writable mount', () => { + expect(selfHostedContainerSettings('test', '/srv/data', 8080, false)).toEqual({ + name: 'atlantis-self-hosted-web-test', + volumes: [{ hostPath: '/srv/data', containerPath: '/data', readOnly: false }], + ports: [{ external: '127.0.0.1:8080', internal: 3000 }] + }); + }); + it('namespaces perf and mounts its data read-only on loopback', () => { + expect(selfHostedContainerSettings('perf', '/srv/data', 8090, true)).toEqual({ + name: 'atlantis-perf-web', + volumes: [{ hostPath: '/srv/data', containerPath: '/data', readOnly: true }], + ports: [{ external: '127.0.0.1:8090', internal: 3000 }] + }); + }); + it.each([ + [8080, true], + [8090, false] + ])('rejects unsafe perf settings', (port, readOnly) => { + expect(() => selfHostedContainerSettings('perf', '/srv/data', port, readOnly)).toThrow(); + }); + it.each(['relative', '/', '/srv/..'])('rejects invalid data directory %s', (directory) => { + expect(() => selfHostedContainerSettings('perf', directory, 8090, true)).toThrow(); + }); + it('requires external perf state while preserving the normal checkout default', () => { + expect(selfHostedStateDirectory(undefined, 'test')).toBe(selfHostedRepoRoot); + expect(selfHostedStateDirectory('/srv/perf', 'perf')).toBe('/srv/perf'); + for (const directory of [ + undefined, + 'relative', + '/', + selfHostedRepoRoot, + `${selfHostedRepoRoot}/state` + ]) { + expect(() => selfHostedStateDirectory(directory, 'perf')).toThrow(); + } + }); +}); diff --git a/package.json b/package.json index b4c5a281..21fb9a1d 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "deploy:cloudflare": "ATLANTIS_DB_DRIVER=d1 alchemy deploy infra/cloudflare.ts", "plan:cloudflare": "ATLANTIS_DB_DRIVER=d1 alchemy plan infra/cloudflare.ts", "destroy:cloudflare": "alchemy destroy infra/cloudflare.ts", - "deploy:self-hosted": "alchemy deploy infra/self-hosted.ts", - "plan:self-hosted": "alchemy plan infra/self-hosted.ts", - "destroy:self-hosted": "alchemy destroy infra/self-hosted.ts", + "deploy:self-hosted": "bun infra/run-self-hosted.ts deploy", + "plan:self-hosted": "bun infra/run-self-hosted.ts plan", + "destroy:self-hosted": "bun infra/run-self-hosted.ts destroy", + "perf": "./scripts/run-with-nix-if-available.sh node apps/web/tests/performance/benchmark.mjs", + "perf:bundle": "bash scripts/perf/export-build.sh", "check:web": "bun run --cwd apps/web check", "lint": "bun run --cwd apps/web lint && bun run --cwd infra lint && cargo clippy --workspace --all-targets --all-features --locked -- -D warnings", "typecheck": "bun run --cwd apps/web typecheck && bun run --cwd infra typecheck && cargo check --workspace --all-targets --locked", diff --git a/scripts/perf/export-build.sh b/scripts/perf/export-build.sh new file mode 100644 index 00000000..e9efda6a --- /dev/null +++ b/scripts/perf/export-build.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/../.." +perf_output="${1:-$(mktemp -d /tmp/atlantis-perf-build.XXXXXX)}" +mkdir -p "$perf_output" +perf_context="${ATLANTIS_PERF_DOCKER_CONTEXT:-atlantis-perf}" +perf_image=atlantis-perf-benchmark-build +docker --context "$perf_context" build --target build --platform linux/amd64 \ + --file apps/web/Dockerfile --tag "$perf_image" . +perf_container=$(docker --context "$perf_context" create --name "atlantis-perf-bundle-$$" "$perf_image") +trap 'docker --context "$perf_context" rm "$perf_container" > /dev/null' EXIT +docker --context "$perf_context" cp "$perf_container:/app/apps/web/.svelte-kit" "$perf_output/" +printf 'Benchmark with --bundle-dir %s/.svelte-kit/output/client\n' "$perf_output"