Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Previews: support creating thumbnails and full-height screenshots #312

Merged
merged 24 commits into from
Jan 26, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8434842
allow scaled thumbnails
ryantxu Jan 6, 2022
44939a8
added support for full page images
ArturWierzbicki Jan 9, 2022
402223b
add `acceptBeforeUnload` handler
ArturWierzbicki Jan 10, 2022
72f315d
use png files
ryantxu Jan 11, 2022
1bcb5c6
produce requested thumbnail size
ryantxu Jan 11, 2022
35d9eef
Update src/browser/browser.ts
ArturWierzbicki Jan 18, 2022
b845641
review fix: move variable away from misleading comment
ArturWierzbicki Jan 18, 2022
7cb0a4c
review fix: added default `headed` option to the default and dev env …
ArturWierzbicki Jan 18, 2022
1369770
review fix: removed ternary expression
ArturWierzbicki Jan 18, 2022
fc308a3
review fix: remove `withFullPageViewport`
ArturWierzbicki Jan 18, 2022
efa7e73
review fix: fixed full page screenshots for non-kiosk mode
ArturWierzbicki Jan 19, 2022
aa56dcb
add script downloading platform-specific sharp binary
ArturWierzbicki Jan 20, 2022
8fe5525
update yarnlock
ArturWierzbicki Jan 20, 2022
bc6f4af
fix package_Target script
ArturWierzbicki Jan 20, 2022
f01214f
update circleci node version
ArturWierzbicki Jan 20, 2022
23ae8ad
update package json
ArturWierzbicki Jan 20, 2022
9310287
fix package.json -> pkg.assets paths
ArturWierzbicki Jan 20, 2022
ab07b2d
downgrade pkg to fix sharp build issue
ArturWierzbicki Jan 21, 2022
abd24d0
working m1 (#318)
ArturWierzbicki Jan 24, 2022
7f176e0
replace `mkdir -p` with `fs.mkdirSync {recursive: true}`
ArturWierzbicki Jan 26, 2022
57b8115
Merge branch 'scaled-thumb-result' of https://github.com/grafana/graf…
ArturWierzbicki Jan 26, 2022
cd6f79e
Update scripts/download_sharp.js
ArturWierzbicki Jan 26, 2022
e5bf620
Update scripts/download_sharp.js
ArturWierzbicki Jan 26, 2022
4bcf42a
Update scripts/package_target.sh
ArturWierzbicki Jan 26, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"prom-client": "^11.5.3",
"puppeteer": "^10.0.0",
"puppeteer-cluster": "^0.22.0",
"sharp": "^0.29.3",
"unique-filename": "^1.1.0",
"winston": "^3.2.1"
},
Expand Down Expand Up @@ -72,7 +73,7 @@
},
"bin": "build/app.js",
"engines": {
"node": ">=14 <15"
"node": ">=14 <=16"
},
"volta": {
"node": "14.16.1"
Expand Down
231 changes: 183 additions & 48 deletions src/browser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as chokidar from 'chokidar';
import * as path from 'path';
import * as fs from 'fs';
import * as promClient from 'prom-client';
import * as sharp from 'sharp';
import { Logger } from '../logger';
import { RenderingConfig } from '../config';
import { ImageRenderOptions, RenderOptions } from '../types';
Expand All @@ -21,6 +22,10 @@ export interface RenderCSVResponse {
fileName?: string;
}

type DashboardScrollingResult = { scrolled: false } | { scrolled: true; scrollHeight: number };

type PuppeteerLaunchOptions = Parameters<typeof puppeteer['launch']>[0];

export class Browser {
constructor(protected config: RenderingConfig, protected log: Logger, protected metrics: Metrics) {
this.log.debug('Browser initialized', 'config', this.config);
Expand Down Expand Up @@ -82,6 +87,12 @@ export class Browser {
options.width = this.config.maxWidth;
}

// Trigger full height snapshots with a negative height value
if (options.height === -1) {
options.fullPageImage = true;
options.height = Math.floor(options.width * 0.75);
}

if (options.height < 10) {
options.height = this.config.height;
}
Expand All @@ -92,7 +103,18 @@ export class Browser {

options.deviceScaleFactor = parseFloat(((options.deviceScaleFactor as string) || '1') as string) || 1;

if (options.deviceScaleFactor > this.config.maxDeviceScaleFactor) {
// Scaled thumbnails
if (options.deviceScaleFactor <= 0) {
options.scaleImage = options.deviceScaleFactor * -1;
options.deviceScaleFactor = 1;

if (options.scaleImage > 1) {
options.width *= options.scaleImage;
options.height *= options.scaleImage;
} else {
options.scaleImage = undefined;
}
} else if (options.deviceScaleFactor > this.config.maxDeviceScaleFactor) {
options.deviceScaleFactor = this.config.deviceScaleFactor;
}
}
Expand All @@ -102,7 +124,7 @@ export class Browser {
// set env timezone
env.TZ = options.timezone || this.config.timezone;

const launcherOptions: any = {
const launcherOptions: PuppeteerLaunchOptions = {
env: env,
ignoreHTTPSErrors: this.config.ignoresHttpsErrors,
dumpio: this.config.dumpio,
Expand All @@ -113,6 +135,8 @@ export class Browser {
launcherOptions.executablePath = this.config.chromeBin;
}

launcherOptions.headless = !this.config.headed;

return launcherOptions;
}

Expand All @@ -139,6 +163,61 @@ export class Browser {
this.log.debug(`Setting extra HTTP headers for page`, 'headers', options.headers);
await page.setExtraHTTPHeaders(options.headers as any);
}

// automatically accept "Changes you made may not be saved" dialog which could be triggered by saving migrated dashboard schema
const acceptBeforeUnload = (dialog) => dialog.type() === 'beforeunload' && dialog.accept();
page.on('dialog', acceptBeforeUnload);
}

async scrollToLoadAllPanels(page: puppeteer.Page, options: ImageRenderOptions): Promise<DashboardScrollingResult> {
const scrollDivSelector = '[class="scrollbar-view"]';
const scrollDelay = options.scrollDelay ?? 500;

await page.waitForSelector(scrollDivSelector);
const dashboardHeights: { scroll: number; client: number } | undefined = await page.evaluate((scrollDivSelector) => {
const div = document.querySelector(scrollDivSelector);
if (!div) {
return undefined;
}
return {
scroll: div.scrollHeight,
client: div.clientHeight,
};
}, scrollDivSelector);

if (!dashboardHeights) {
return {
scrolled: false,
};
}

const scrolls = Math.floor(dashboardHeights.scroll / dashboardHeights.client);

if (scrolls < 1 || dashboardHeights.scroll === dashboardHeights.client) {
return {
scrolled: false,
};
}
ArturWierzbicki marked this conversation as resolved.
Show resolved Hide resolved

for (let i = 0; i < scrolls; i++) {
await page.evaluate(
(scrollByHeight, scrollDivSelector) => {
document.querySelector(scrollDivSelector)?.scrollBy(0, scrollByHeight);
},
dashboardHeights.client,
scrollDivSelector
);
await page.waitForTimeout(scrollDelay);
}

await page.evaluate((scrollDivSelector) => {
document.querySelector(scrollDivSelector)?.scrollTo(0, 0);
}, scrollDivSelector);

return {
scrolled: true,
scrollHeight: dashboardHeights.scroll,
};
}

async render(options: ImageRenderOptions): Promise<RenderResponse> {
Expand Down Expand Up @@ -170,57 +249,97 @@ export class Browser {
}
}

async takeScreenshot(page: any, options: ImageRenderOptions): Promise<RenderResponse> {
await this.withTimingMetrics(async () => {
if (this.config.verboseLogging) {
this.log.debug(
'Setting viewport for page',
'width',
options.width.toString(),
'height',
options.height.toString(),
'deviceScaleFactor',
options.deviceScaleFactor
);
}
private setViewport = async (page: puppeteer.Page, options: ImageRenderOptions): Promise<void> => {
await page.setViewport({
width: +options.width,
height: +options.height,
deviceScaleFactor: options.deviceScaleFactor ? +options.deviceScaleFactor : 1,
});
};

await page.setViewport({
width: options.width,
height: options.height,
deviceScaleFactor: options.deviceScaleFactor,
});
withFullPageViewport = (
fn: () => Promise<unknown>,
page: puppeteer.Page,
options: ImageRenderOptions,
scrollHeight: number
): (() => Promise<void>) => async () => {
await this.setViewport(page, {
...options,
height: scrollHeight,
});
await fn();
await this.setViewport(page, options);
};

await this.preparePage(page, options);
await this.setTimezone(page, options);
async takeScreenshot(page: puppeteer.Page, options: ImageRenderOptions): Promise<RenderResponse> {
try {
await this.withTimingMetrics(async () => {
if (this.config.verboseLogging) {
this.log.debug(
'Setting viewport for page',
'width',
options.width.toString(),
'height',
options.height.toString(),
'deviceScaleFactor',
options.deviceScaleFactor
);
}

if (this.config.verboseLogging) {
this.log.debug('Moving mouse on page', 'x', options.width, 'y', options.height);
}
return page.mouse.move(options.width, options.height);
}, 'prepare');
await this.setViewport(page, options);

await this.withTimingMetrics<void>(() => {
if (this.config.verboseLogging) {
this.log.debug('Navigating and waiting for all network requests to finish', 'url', options.url);
}
await this.preparePage(page, options);
await this.setTimezone(page, options);

return page.goto(options.url, { waitUntil: 'networkidle0', timeout: options.timeout * 1000 });
}, 'navigate');
if (this.config.verboseLogging) {
this.log.debug('Moving mouse on page', 'x', options.width, 'y', options.height);
}
return page.mouse.move(+options.width, +options.height);
}, 'prepare');

await this.withTimingMetrics<void>(() => {
if (this.config.verboseLogging) {
this.log.debug('Waiting for dashboard/panel to load', 'timeout', `${options.timeout}s`);
await this.withTimingMetrics(() => {
if (this.config.verboseLogging) {
this.log.debug('Navigating and waiting for all network requests to finish', 'url', options.url);
}

return page.goto(options.url, { waitUntil: 'networkidle0', timeout: options.timeout * 1000 });
}, 'navigate');
} catch (err) {
this.log.error('Error while trying to prepare page for screenshot', 'url', options.url, 'err', err.stack);
}

let scrollResult: DashboardScrollingResult = {
scrolled: false,
};

if (options.fullPageImage) {
try {
scrollResult = await this.withTimingMetrics(() => {
return this.scrollToLoadAllPanels(page, options);
}, 'dashboardScrolling');
} catch (err) {
this.log.error('Error while scrolling to load all panels', 'url', options.url, 'err', err.stack);
}
return page.waitForFunction(
() => {
const panelCount = document.querySelectorAll('.panel').length || document.querySelectorAll('.panel-container').length;
return (window as any).panelsRendered >= panelCount;
},
{
timeout: options.timeout * 1000,
}

try {
await this.withTimingMetrics(() => {
if (this.config.verboseLogging) {
this.log.debug('Waiting for dashboard/panel to load', 'timeout', `${options.timeout}s`);
}
);
}, 'panelsRendered');
return page.waitForFunction(
() => {
const panelCount = document.querySelectorAll('.panel').length || document.querySelectorAll('.panel-container').length;
return (window as any).panelsRendered >= panelCount;
},
{
timeout: options.timeout * 1000,
}
);
}, 'panelsRendered');
} catch (err) {
this.log.error('Error while waiting for the panels to load', 'url', options.url, 'err', err.stack);
}

if (!options.filePath) {
options.filePath = uniqueFilename(os.tmpdir()) + '.png';
Expand All @@ -230,9 +349,25 @@ export class Browser {
this.log.debug('Taking screenshot', 'filePath', options.filePath);
}

await this.withTimingMetrics<void>(() => {
return page.screenshot({ path: options.filePath });
}, 'screenshot');
const screenshotFn = () =>
page.screenshot({ path: options.filePath, fullPage: options.fullPageImage, captureBeyondViewport: options.fullPageImage });
await this.withTimingMetrics(
scrollResult.scrolled ? this.withFullPageViewport(screenshotFn, page, options, scrollResult.scrollHeight) : screenshotFn,
'screenshot'
);
AgnesToulet marked this conversation as resolved.
Show resolved Hide resolved

if (options.scaleImage) {
const scaled = uniqueFilename(os.tmpdir()) + '.webp';
AgnesToulet marked this conversation as resolved.
Show resolved Hide resolved
await sharp(options.filePath)
.resize(320, 240, { fit: 'inside' })
.toFormat('webp', {
quality: 70, // 80 is default
})
.toFile(scaled);

fs.unlink(options.filePath, () => {});
options.filePath = scaled;
}

return { filePath: options.filePath };
}
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface RenderingConfig {
verboseLogging: boolean;
dumpio: boolean;
timingMetrics: boolean;
headed?: boolean;
AgnesToulet marked this conversation as resolved.
Show resolved Hide resolved
}

export interface MetricsConfig {
Expand Down Expand Up @@ -70,6 +71,7 @@ const defaultRenderingConfig: RenderingConfig = {
acceptLanguage: undefined,
width: 1000,
height: 500,
headed: false,
deviceScaleFactor: 1,
maxWidth: 3000,
maxHeight: 3000,
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ export interface ImageRenderOptions extends RenderOptions {
width: string | number;
height: string | number;
deviceScaleFactor?: string | number;

// Runtime options derived from the input
fullPageImage?: boolean;
scrollDelay?: number;
AgnesToulet marked this conversation as resolved.
Show resolved Hide resolved
scaleImage?: number;
AgnesToulet marked this conversation as resolved.
Show resolved Hide resolved
}
Loading