From 3c39206a5951bba96f0acafaa3fc3abd5c052f05 Mon Sep 17 00:00:00 2001 From: Didi Hoffmann Date: Wed, 12 Aug 2026 10:03:07 +0200 Subject: [PATCH] Adds the option to submit your own script --- css/style.css | 59 ++++++++++++++++ index.html | 19 ++++++ js/code.js | 58 ++++++++++------ js/script-details.js | 156 +++++++++++++++++++++++++++++++++++++++++++ js/shared.js | 35 ++++++++++ script-details.html | 101 ++++++++++++++++++++++++++++ 6 files changed, 408 insertions(+), 20 deletions(-) create mode 100644 js/script-details.js create mode 100644 script-details.html diff --git a/css/style.css b/css/style.css index 6cdc3a6..3dba4bc 100644 --- a/css/style.css +++ b/css/style.css @@ -54,6 +54,65 @@ div[class*="left aligned"] { max-width: 350px; } +#script-toggle > summary { + cursor: pointer; + text-align: left; + padding: 8px 0; + font-weight: bold; +} + +#script-toggle .script-optional { + font-weight: normal; + color: #888; +} + +#script-container { + text-align: left; +} + +#script-container .script-note { + font-size: 12px; + line-height: 1.5; +} + +#script-container .script-note code { + background: rgba(0, 0, 0, .06); + padding: 1px 4px; + border-radius: 3px; +} + +/* semantic sets width:100% on .ui.form select and .ui.form textarea, which stretches both over the full column */ +#script-language { + width: auto; + margin-bottom: 8px; +} + +#script-input { + box-sizing: border-box; /* the reset does not reach in here, so padding would widen the field */ + max-width: 680px; /* keeps the code area at a readable line length */ +} + +#script-input, +.script-source { + font-family: monospace; + font-size: 13px; + line-height: 1.5; + tab-size: 2; +} + +.script-source { + background: rgba(0, 0, 0, .06); + padding: 12px; + border-radius: 4px; + overflow-x: auto; + white-space: pre; +} + +#script-waiting { + text-align: center; + padding: 40px 0; +} + .color-score { padding-inline-start: 0px; margin-block-end: 0px; diff --git a/index.html b/index.html index 1301ef4..1cf7639 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,25 @@

+ Add a page --> +
+
+ Your Playwright path (optional) +
+

+ Your path runs after we have opened your page, so browser, context and page are already loaded and ready to use. + Only the Playwright commands themselves are needed — anything more complex like import or require will not work. +

+ + +
+
+
diff --git a/js/code.js b/js/code.js index 2ef5d2a..59af3d0 100644 --- a/js/code.js +++ b/js/code.js @@ -13,6 +13,7 @@ const formData = new FormData(this); const email = formData.get('email').trim(); + const script = formData.get('script').trim(); let normalized_url; try { @@ -23,35 +24,44 @@ return false; } - // first we check if we already have a run in the last 30 days for this - let last_run = null; - try { - const modded_date = new Date(); - modded_date.setDate(modded_date.getDate() - 30); - const thirty_days_ago = modded_date.toISOString().split('T')[0]; - - last_run = await fetchData(1, normalized_url, thirty_days_ago); - } catch (error) { - alert('Could not check in DB for already present runs. Please try again later'); - console.error('Error:', error); - form.classList.remove('loading'); form_button.disabled = false; - return false; - } - if (last_run != null) { - alert('We already have a run for this URL in the last 30 days - You will now be redirected to the details page'); - window.location = `/details.html?page=${encodeURIComponent(normalized_url)}`; - form.classList.remove('loading'); form_button.disabled = false; - return false; + // Runs with a custom Playwright path are not deduplicated, as the same URL can be walked in many ways + if (script === '') { + // first we check if we already have a run in the last 30 days for this + let last_run = null; + try { + const modded_date = new Date(); + modded_date.setDate(modded_date.getDate() - 30); + const thirty_days_ago = modded_date.toISOString().split('T')[0]; + + last_run = await fetchData(1, normalized_url, thirty_days_ago); + } catch (error) { + alert('Could not check in DB for already present runs. Please try again later'); + console.error('Error:', error); + form.classList.remove('loading'); form_button.disabled = false; + return false; + } + if (last_run != null) { + alert('We already have a run for this URL in the last 30 days - You will now be redirected to the details page'); + window.location = `/details.html?page=${encodeURIComponent(normalized_url)}`; + form.classList.remove('loading'); form_button.disabled = false; + return false; + } } const dataToSend = { email: email, page: normalized_url, - mode: 'website', + mode: script === '' ? 'website' : 'website-script', schedule_mode: formData.get('schedule_mode'), }; + if (script !== '') { + dataToSend.script = script; + dataToSend.language = formData.get('language'); + } + + let job_id = null; try { const response = await fetch('https://gateway.green-coding.io/save', { method: 'POST', @@ -68,6 +78,8 @@ return false; } + job_id = (await response.json())?.data?.job_id; + } catch (error) { console.error('Error:', error); alert('An error occurred. Check console for details.'); @@ -75,6 +87,12 @@ return false; } + // Custom Playwright paths are not listed under the recent runs, so the job id is the only handle the user has + if (script !== '' && job_id != null) { + window.location = `/script-details.html?job_id=${encodeURIComponent(job_id)}`; + return false; + } + if (email === '') { alert('Thanks, we have received your measurement request and can find your results on this page shortly!', 'Success :)'); } else { diff --git a/js/script-details.js b/js/script-details.js new file mode 100644 index 0000000..be84827 --- /dev/null +++ b/js/script-details.js @@ -0,0 +1,156 @@ +"use strict"; + +const SCRIPT_PHASE = 'Run Playwright path'; +const SCRIPT_FILENAME = 'templates/website/usage_scenario_playwright_js_cached.yml'; +const POLL_INTERVAL_MS = 30_000; +const POLL_MAX_MS = 90 * 60 * 1000; // runs that take longer than this will not have failed, we just stop waiting + +(async () => { + const url_params = getURLParams(); + const job_id = url_params?.job_id; + + const waiting_el = document.querySelector('#script-waiting'); + const error_el = document.querySelector('#script-error'); + const results_el = document.querySelector('#script-results'); + + const showError = (message, uuid=null) => { + waiting_el.style.display = 'none'; + error_el.style.display = ''; + document.querySelector('#script-error-message').textContent = message; + const link = document.querySelector('#error-details-link'); + if (uuid == null) { + link.parentElement.style.display = 'none'; + } else { + link.href = `https://metrics.green-coding.io/stats.html?id=${uuid}`; + } + }; + + if (job_id == null || !/^\d+$/.test(job_id)) { + showError('This link is missing a valid job id. Did you follow a correct link?'); + return; + } + + // The run only shows up once the job has been picked up by a measurement machine and has finished + const started_at = Date.now(); + let run = null; + while (run == null) { + try { + run = await fetchRunByJobId(job_id); + } catch (error) { + console.error('Error:', error); + showError('Could not reach the API to check on your measurement. Please reload this page in a few minutes.'); + return; + } + + if (run != null) break; + + if (Date.now() - started_at > POLL_MAX_MS) { + showError('Your measurement is taking unusually long. It is most likely still queued - please reload this page later.'); + return; + } + + document.querySelector('#waiting-duration').textContent = Math.round((Date.now() - started_at) / 60_000); + await sleep(POLL_INTERVAL_MS); + } + + const uuid = run[0]; + const usage_scenario_variables = run[7]; + const failed = run[11]; + const last_run_date = new Date(run[4]); + + if (failed === true) { + showError('Your Playwright path could not be measured. The most common reasons are a command that does not match the page or a path that takes longer than 60 seconds.', uuid); + return; + } + + let phase_stats_response; + try { + phase_stats_response = await fetch(`https://api.green-coding.io/v1/phase_stats/single/${uuid}`); + } catch (error) { + console.error('Error:', error); + showError('Could not fetch the measurement results from the API. Please reload this page in a few minutes.', uuid); + return; + } + + if (!phase_stats_response.ok || phase_stats_response.status == 204) { + showError('The measurement finished, but no results are available (yet). Please reload this page in a few minutes.', uuid); + return; + } + + const data = (await phase_stats_response.json()).data; + const phase_data = data?.['data']?.[SCRIPT_PHASE]?.['data']; + + if (phase_data == null) { + showError('The measurement finished, but did not contain any data for your Playwright path.', uuid); + return; + } + + const cpu_energy_uJ = phase_data?.['cpu_energy_rapl_msr_component']?.['data']?.['Package_0']?.['data']?.[uuid]?.['mean']; + const cpu_energy_mWh = cpu_energy_uJ/3_600_000; + + const cpu_power_mW = phase_data?.['cpu_power_rapl_msr_component']?.['data']?.['Package_0']?.['data']?.[uuid]?.['mean']; + const cpu_power_W = cpu_power_mW/1_000; + + const total_duration_us = phase_data?.['phase_time_syscall_system']?.['data']?.['[SYSTEM]']?.['data']?.[uuid]?.['mean']; + const total_duration_s = total_duration_us/1e6; + + const network_transfer_bytes = phase_data?.['network_total_cgroup_container']?.['data']?.['gmt-playwright-nodejs']?.['data']?.[uuid]?.['mean']; + const network_transfer_kb = network_transfer_bytes/1000; + + const network_carbon_ug = phase_data?.['network_carbon_formula_global']?.['data']?.['[FORMULA]']?.['data']?.[uuid]?.['mean']; + const network_carbon_g = network_carbon_ug/1_000_000; + + const INTENSITY_LEVEL_MAP = { + 1: { label: 'Low', color: 'green' }, + 2: { label: 'Moderate', color: 'yellow' }, + 3: { label: 'High', color: 'red' }, + }; + // In DB this is the naming (merged) as underscores separate scopes / domains + const carbon_intensity_level = phase_data?.['carbon_intensitylevel_electricitymaps_machine']?.['data']?.['electricity_maps']?.['data']?.[uuid]?.['mean']; + + const carbon_intensity_data = phase_data?.['carbon_intensity_elephant_machine']?.['data']; + const carbon_intensity_detail = carbon_intensity_data ? Object.keys(carbon_intensity_data)[0] : null; + const carbon_intensity_gco2_kwh = carbon_intensity_detail + ? carbon_intensity_data?.[carbon_intensity_detail]?.['data']?.[uuid]?.['mean'] + : null; + + const page = usage_scenario_variables?.['__GMT_VAR_PAGE__']; + + document.title = `webNRG - Playwright path for ${page}`; + document.querySelector('#website-name').textContent = page; + document.querySelector('#last-run-date').textContent = last_run_date; + + const formatOrUnknown = (value, formatter) => (Number.isFinite(value) ? formatter(value) : 'N/A'); + + document.querySelector('#rendering-power').textContent = formatOrUnknown(cpu_power_W, (v) => `${v.toFixed(2)} W`); + document.querySelector('#measurement-duration').textContent = formatOrUnknown(total_duration_s, (v) => `${v.toFixed(2)} s`); + document.querySelector('#rendering-energy').textContent = formatOrUnknown(cpu_energy_mWh, (v) => `${v.toFixed(2)} mWh`); + document.querySelector('#network-transfer').textContent = formatOrUnknown(network_transfer_kb, (v) => `${v.toFixed(2)} kB`); + document.querySelector('#network-carbon').textContent = formatOrUnknown(network_carbon_g, (v) => `${v.toFixed(4)} gCO₂e`); + document.querySelector('#carbon-intensity-value').textContent = formatOrUnknown(carbon_intensity_gco2_kwh, (v) => `${Math.round(v)} gCO₂e/kWh`); + + const intensity_el = document.querySelector('#carbon-intensity-level'); + const level = carbon_intensity_level != null ? INTENSITY_LEVEL_MAP[Math.round(carbon_intensity_level)] : null; + if (level) { + intensity_el.textContent = level.label; + intensity_el.classList.add(level.color); + } else { + intensity_el.style.display = 'none'; + } + + const script_base64 = usage_scenario_variables?.['__GMT_VAR_SCRIPT_B64__']; + if (script_base64 != null) { + document.querySelector('#script-source').textContent = decodeBase64(script_base64); + } + + const usage_scenario_variables_params = Object.entries(usage_scenario_variables) + .map(([k, v]) => `usage_scenario_variables[${k}]=${encodeURIComponent(v)}`) + .join('&'); + + document.querySelector('#measurement-details-link').href = `https://metrics.green-coding.io/stats.html?id=${uuid}`; + document.querySelector('#timeline-link').href = `https://metrics.green-coding.io/timeline.html?uri=https%3A%2F%2Fgithub.com%2Fgreen-coding-solutions%2Fgreen-metrics-tool&branch=main&machine_id=6&filename=${encodeURIComponent(SCRIPT_FILENAME)}&${usage_scenario_variables_params}&phase=${encodeURIComponent(SCRIPT_PHASE)}&metrics=key`; + + waiting_el.style.display = 'none'; + results_el.style.display = ''; + +})() diff --git a/js/shared.js b/js/shared.js index 482b0d7..fdfdbbd 100644 --- a/js/shared.js +++ b/js/shared.js @@ -55,6 +55,41 @@ async function fetchData(limit=10, usage_scenario_variables='', start_date=null) } +/* + Fetches a single run by the job id that the gateway returned on submit. + Returns null while the job is still queued or the measurement has not finished yet. +*/ +async function fetchRunByJobId(job_id) { + const response = await fetch(`https://api.green-coding.io/v2/runs?job_id=${encodeURIComponent(job_id)}&limit=1`); + + if (!response.ok) { + console.error('Error fetching run:', response); + throw new Error(`API returned ${response.status}`); + } + if (response.status == 204) return null; // job has not produced a run yet + + const run = (await response.json())?.data?.[0]; + if (run == null) return null; + + const end_measurement = run[10]; + const failed = run[11]; + + if (failed !== true && end_measurement == null) return null; // run has started, but is not done yet + + return run; +} + +// atob() alone only handles latin1, so we have to decode the UTF-8 bytes ourselves +function decodeBase64(base64) { + const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + + const getURLParams = () => { const url_params = new URLSearchParams(window.location.search); if (!url_params.size) return {}; diff --git a/script-details.html b/script-details.html new file mode 100644 index 0000000..ba2c85b --- /dev/null +++ b/script-details.html @@ -0,0 +1,101 @@ + + + + + + + + + webNRG - Measure energy and CO2 cost of your Playwright path + + + + + + + + + + + + + +
+
+ +

+ +
webNRG⚡️
+
... pronounced web energy ;)
+

+
+ +
+
+

Measuring your Playwright path ...

+

This usually takes between 5 and 30 minutes. You can bookmark this page and come back later — it keeps checking on its own.

+

Waiting for 0 minutes

+
+ + + + +
+
+
+

How does it work? 👉 Read about the methodology here

+
+
+ + +
+
+ +