From 698e42f073d2a9b7f9c75a32b1093c42e8fe7cbb Mon Sep 17 00:00:00 2001 From: Arne Tarara Date: Wed, 16 Aug 2023 10:29:52 +0200 Subject: [PATCH] Timeline comparison (#420) * Multi Commit View deactivated * Added timeline base functionality * Added compare feature * Added update tool * commit_timestamp must be time stamp with time zone * Bugfix: When Repeated Run is selected, but only one item is found (missing phase_stats) still show correct table entries * Merge * WIP * Bugfix missed to add in commit * Config variables now in config.js.example * Added commit color info * HTML validity fixes * Badge click fix * Test fix for sanitze * Sane is now short and default for YMD display * Added sorting, value formatting, date showing and display of the URI, filename and branch on the landing page --- api/api.py | 104 +++++--- api/api_helpers.py | 332 +++++++++----------------- frontend/ci.html | 7 +- frontend/compare.html | 4 +- frontend/css/green-coding.css | 2 +- frontend/js/helpers/charts.js | 54 ++++- frontend/js/helpers/config.js.example | 228 ++++++++++++++++++ frontend/js/helpers/main.js | 4 +- frontend/js/helpers/metric-boxes.js | 41 ++-- frontend/js/helpers/phase-stats.js | 40 ++-- frontend/js/stats.js | 4 +- frontend/js/timeline.js | 282 ++++++++++++++++++++++ frontend/request.html | 4 +- frontend/settings.html | 4 +- frontend/stats.html | 6 +- frontend/timeline.html | 257 ++++++++++++++++++++ lib/utils.py | 2 +- test/api/test_api_helpers.py | 18 +- tools/jobs.py | 6 +- tools/update_commit_data.py | 56 +++++ 20 files changed, 1113 insertions(+), 342 deletions(-) create mode 100644 frontend/js/timeline.js create mode 100644 frontend/timeline.html create mode 100644 tools/update_commit_data.py diff --git a/api/api.py b/api/api.py index 27dcd17ad..105c03b2f 100644 --- a/api/api.py +++ b/api/api.py @@ -3,7 +3,6 @@ # pylint: disable=no-name-in-module # pylint: disable=wrong-import-position -import json import faulthandler import sys import os @@ -26,11 +25,11 @@ import jobs import email_helpers import error_helpers -import psycopg import anybadge from api_helpers import (add_phase_stats_statistics, determine_comparison_case, - sanitize, get_phase_stats, get_phase_stats_object, - is_valid_uuid, rescale_energy_value) + html_escape_multi, get_phase_stats, get_phase_stats_object, + is_valid_uuid, rescale_energy_value, get_timeline_query, + get_project_info, get_machine_list) # It seems like FastAPI already enables faulthandler as it shows stacktrace on SEGFAULT # Is the redundant call problematic @@ -126,18 +125,14 @@ async def get_notes(project_id): if data is None or data == []: return Response(status_code=204) # No-Content - escaped_data = [sanitize(note) for note in data] + escaped_data = [html_escape_multi(note) for note in data] return ORJSONResponse({'success': True, 'data': escaped_data}) # return a list of all possible registered machines @app.get('/v1/machines/') async def get_machines(): - query = """ - SELECT id, description, available - FROM machines - ORDER BY description ASC - """ - data = DB().fetch_all(query) + + data = get_machine_list() if data is None or data == []: return Response(status_code=204) # No-Content @@ -171,7 +166,7 @@ async def get_projects(repo: str, filename: str): if data is None or data == []: return Response(status_code=204) # No-Content - escaped_data = [sanitize(project) for project in data] + escaped_data = [html_escape_multi(project) for project in data] return ORJSONResponse({'success': True, 'data': escaped_data}) @@ -201,12 +196,10 @@ async def compare_in_repo(ids: str): phase_stats_object = add_phase_stats_statistics(phase_stats_object) phase_stats_object['common_info'] = {} - project_info_response = await get_project(ids[0]) - project_info = json.loads(project_info_response.body)['data'] + project_info = get_project_info(ids[0]) - machines_response = await get_machines() - machines_info = json.loads(machines_response.body)['data'] - machines = {machine[0]: machine[1] for machine in machines_info} + machine_list = get_machine_list() + machines = {machine[0]: machine[1] for machine in machine_list} machine = machines[project_info['machine_id']] uri = project_info['uri'] @@ -259,7 +252,7 @@ async def compare_in_repo(ids: str): return ORJSONResponse({'success': True, 'data': phase_stats_object}) -# This route is primarily used to load phase stats it into a pandas data frame + @app.get('/v1/phase_stats/single/{project_id}') async def get_phase_stats_single(project_id: str): if project_id is None or not is_valid_uuid(project_id): @@ -302,6 +295,56 @@ async def get_measurements_single(project_id: str): return ORJSONResponse({'success': True, 'data': data}) +@app.get('/v1/timeline') +async def get_timeline_stats(uri: str, machine_id: int, branch: str | None = None, filename: str | None = None, start_date: str | None = None, end_date: str | None = None, metrics: str | None = None, phase: str | None = None, sorting: str | None = None,): + if uri is None or uri.strip() == '': + return ORJSONResponse({'success': False, 'err': 'URI is empty'}, status_code=400) + + query, params = get_timeline_query(uri,filename,machine_id, branch, metrics, phase, start_date=start_date, end_date=end_date, sorting=sorting) + + data = DB().fetch_all(query, params=params) + + if data is None or data == []: + return Response(status_code=204) # No-Content + + return ORJSONResponse({'success': True, 'data': data}) + +@app.get('/v1/badge/timeline') +async def get_timeline_badge(detail_name: str, uri: str, machine_id: int, branch: str | None = None, filename: str | None = None, metrics: str | None = None, phase: str | None = None): + if uri is None or uri.strip() == '': + return ORJSONResponse({'success': False, 'err': 'URI is empty'}, status_code=400) + + if detail_name is None or detail_name.strip() == '': + return ORJSONResponse({'success': False, 'err': 'Detail Name is mandatory'}, status_code=400) + + query, params = get_timeline_query(uri,filename,machine_id, branch, metrics, phase, detail_name=detail_name, limit_365=True) + + query = f""" + WITH trend_data AS ( + {query} + ) SELECT + MAX(row_num::float), + regr_slope(value, row_num::float) AS trend_slope, + regr_intercept(value, row_num::float) AS trend_intercept, + MAX(unit) + FROM trend_data; + """ + + data = DB().fetch_one(query, params=params) + + if data is None or data == [] or data[1] is None: # special check for data[1] as this is aggregate query which always returns result + return Response(status_code=204) # No-Content + + cost = data[1]/data[0] + cost = f"+{round(float(cost), 2)}" if abs(cost) == cost else f"{round(float(cost), 2)}" + + badge = anybadge.Badge( + label=xml_escape('Project Trend'), + value=xml_escape(f"{cost} {data[3]} per day"), + num_value_padding_chars=1, + default_color='orange') + return Response(content=str(badge), media_type="image/svg+xml") + # A route to return all of the available entries in our catalog. @app.get('/v1/badge/single/{project_id}') @@ -342,7 +385,7 @@ async def get_badge_single(project_id: str, metric: str = 'ml-estimated'): params = (project_id, value) data = DB().fetch_one(query, params=params) - if data is None or data == [] or not data[1] : + if data is None or data == [] or data[1] is None: # special check for data[1] as this is aggregate query which always returns result badge_value = 'No energy data yet' else: [energy_value, energy_unit] = rescale_energy_value(data[0], data[1]) @@ -383,7 +426,7 @@ async def post_project_add(project: Project): if project.machine_id == 0: project.machine_id = None - project = sanitize(project) + project = html_escape_multi(project) # Note that we use uri here as the general identifier, however when adding through web interface we only allow urls query = """ @@ -412,23 +455,12 @@ async def get_project(project_id: str): if project_id is None or not is_valid_uuid(project_id): return ORJSONResponse({'success': False, 'err': 'Project ID is not a valid UUID or empty'}, status_code=400) - query = """ - SELECT - id, name, uri, branch, commit_hash, - (SELECT STRING_AGG(t.name, ', ' ) FROM unnest(projects.categories) as elements - LEFT JOIN categories as t on t.id = elements) as categories, - filename, start_measurement, end_measurement, - measurement_config, machine_specs, machine_id, usage_scenario, - last_run, created_at, invalid_project, phases, logs - FROM projects - WHERE id = %s - """ - params = (project_id,) - data = DB().fetch_one(query, params=params, row_factory=psycopg.rows.dict_row) + data = get_project_info(project_id) + if data is None or data == []: return Response(status_code=204) # No-Content - data = sanitize(data) + data = html_escape_multi(data) return ORJSONResponse({'success': True, 'data': data}) @@ -484,7 +516,7 @@ async def post_ci_measurement_add(measurement: CI_Measurement): if value.strip() == '': return ORJSONResponse({'success': False, 'err': f"{key} is empty"}, status_code=400) - measurement = sanitize(measurement) + measurement = html_escape_multi(measurement) query = """ INSERT INTO @@ -543,7 +575,7 @@ async def get_ci_badge_get(repo: str, branch: str, workflow:str): params = (repo, branch, workflow) data = DB().fetch_one(query, params=params) - if data is None or data == []: + if data is None or data == [] or data[1] is None: # special check for data[1] as this is aggregate query which always returns result return Response(status_code=204) # No-Content energy_value = data[0] diff --git a/api/api_helpers.py b/api/api_helpers.py index ddf170bdf..4284e921f 100644 --- a/api/api_helpers.py +++ b/api/api_helpers.py @@ -6,6 +6,7 @@ import faulthandler from functools import cache from html import escape as html_escape +import psycopg import numpy as np import scipy.stats # pylint: disable=no-name-in-module @@ -18,220 +19,6 @@ from db import DB - -METRIC_MAPPINGS = { - - - 'embodied_carbon_share_machine': { - 'clean_name': 'Embodied Carbon', - 'source': 'formula', - 'explanation': 'Embodied carbon attributed by time share of the life-span and total embodied carbon', - }, - 'software_carbon_intensity_global': { - 'clean_name': 'SCI', - 'source': 'formula', - 'explanation': 'SCI metric by the Green Software Foundation', - }, - 'phase_time_syscall_system': { - 'clean_name': 'Phase Duration', - 'source': 'Syscall', - 'explanation': 'Duration of the phase measured by GMT through a syscall', - }, - 'psu_co2_ac_ipmi_machine': { - 'clean_name': 'Machine CO2', - 'source': 'Formula (IPMI)', - 'explanation': 'Machine CO2 calculated by formula via IPMI measurement', - }, - 'psu_co2_dc_picolog_mainboard': { - 'clean_name': 'Machine CO2', - 'source': 'Formula (PicoLog)', - 'explanation': 'Machine CO2 calculated by formula via PicoLog HRDL ADC-24 measurement', - }, - 'psu_co2_ac_powerspy2_machine': { - 'clean_name': 'Machine CO2', - 'source': 'PowerSpy2', - 'explanation': 'Machine CO2 calculated by formula via PowerSpy2 measurement', - }, - 'psu_co2_ac_xgboost_machine': { - 'clean_name': 'Machine CO2', - 'source': 'Formula (XGBoost)', - 'explanation': 'Machine CO2 calculated by formula via XGBoost estimation', - }, - 'network_energy_formula_global': { - 'clean_name': 'Network Energy', - 'source': 'Formula', - 'explanation': 'Network Energy calculated by formula', - }, - 'network_co2_formula_global': { - 'clean_name': 'Network CO2', - 'source': 'Formula', - 'explanation': 'Network CO2 calculated by formula', - }, - 'lm_sensors_temperature_component': { - 'clean_name': 'CPU Temperature', - 'source': 'lm_sensors', - 'explanation': 'CPU Temperature as reported by lm_sensors', - }, - 'lm_sensors_fan_component': { - 'clean_name': 'Fan Speed', - 'source': 'lm_sensors', - 'explanation': 'Fan speed as reported by lm_sensors', - }, - 'psu_energy_ac_powerspy2_machine': { - 'clean_name': 'Machine Energy', - 'source': 'PowerSpy2', - 'explanation': 'Full machine energy (AC) as reported by PowerSpy2', - }, - 'psu_power_ac_powerspy2_machine': { - 'clean_name': 'Machine Power', - 'source': 'PowerSpy2', - 'explanation': 'Full machine power (AC) as reported by PowerSpy2', - }, - 'psu_energy_ac_xgboost_machine': { - 'clean_name': 'Machine Energy', - 'source': 'XGBoost', - 'explanation': 'Full machine energy (AC) as estimated by XGBoost model', - }, - 'psu_power_ac_xgboost_machine': { - 'clean_name': 'Machine Power', - 'source': 'XGBoost', - 'explanation': 'Full machine power (AC) as estimated by XGBoost model', - }, - 'psu_energy_ac_ipmi_machine': { - 'clean_name': 'Machine Energy', - 'source': 'IPMI', - 'explanation': 'Full machine energy (AC) as reported by IPMI', - }, - 'psu_power_ac_ipmi_machine': { - 'clean_name': 'Machine Power', - 'source': 'IPMI', - 'explanation': 'Full machine power (AC) as reported by IPMI', - }, - 'psu_energy_dc_picolog_mainboard': { - 'clean_name': 'Machine Energy', - 'source': 'PicoLog', - 'explanation': 'Full machine energy (DC) as reported by PicoLog HRDL ADC-24', - }, - 'psu_power_dc_picolog_mainboard': { - 'clean_name': 'Machine Power', - 'source': 'Picolog', - 'explanation': 'Full machine power (DC) as reported by PicoLog HRDL ADC-24', - }, - 'cpu_frequency_sysfs_core': { - 'clean_name': 'CPU Frequency', - 'source': 'sysfs', - 'explanation': 'CPU Frequency per core as reported by sysfs', - }, - 'ane_power_powermetrics_component': { - 'clean_name': 'ANE Power', - 'source': 'powermetrics', - 'explanation': 'Apple Neural Engine', - }, - 'ane_energy_powermetrics_component': { - 'clean_name': 'ANE Energy', - 'source': 'powermetrics', - 'explanation': 'Apple Neural Engine', - }, - 'gpu_power_powermetrics_component': { - 'clean_name': 'GPU Power', - 'source': 'powermetrics', - 'explanation': 'Apple M1 GPU / Intel GPU', - }, - 'gpu_energy_powermetrics_component': { - 'clean_name': 'GPU Energy', - 'source': 'powermetrics', - 'explanation': 'Apple M1 GPU / Intel GPU', - }, - 'cores_power_powermetrics_component': { - 'clean_name': 'CPU Power (Cores)', - 'source': 'powermetrics', - 'explanation': 'Power of the cores only without GPU, ANE, GPU, DRAM etc.', - }, - 'cores_energy_powermetrics_component': { - 'clean_name': 'CPU Energy (Cores)', - 'source': 'powermetrics', - 'explanation': 'Energy of the cores only without GPU, ANE, GPU, DRAM etc.', - }, - 'cpu_time_powermetrics_vm': { - 'clean_name': 'CPU time', - 'source': 'powermetrics', - 'explanation': 'Effective execution time of the CPU for all cores combined', - }, - 'disk_io_bytesread_powermetrics_vm': { - 'clean_name': 'Bytes read (HDD/SDD)', - 'source': 'powermetrics', - 'explanation': 'Effective execution time of the CPU for all cores combined', - }, - 'disk_io_byteswritten_powermetrics_vm': { - 'clean_name': 'Bytes written (HDD/SDD)', - 'source': 'powermetrics', - 'explanation': 'Effective execution time of the CPU for all cores combined', - }, - 'energy_impact_powermetrics_vm': { - 'clean_name': 'Energy impact', - 'source': 'powermetrics', - 'explanation': 'macOS proprietary value for relative energy impact on device', - }, - 'cpu_utilization_cgroup_container': { - 'clean_name': 'CPU %', - 'source': 'cgroup', - 'explanation': 'CPU Utilization per container', - }, - 'memory_total_cgroup_container': { - 'clean_name': 'Memory Usage', - 'source': 'cgroup', - 'explanation': 'Memory Usage per container', - }, - 'network_io_cgroup_container': { - 'clean_name': 'Network I/O', - 'source': 'cgroup', - 'explanation': 'Network I/O. Details on docs.green-coding.berlin/docs/measuring/metric-providers/network-io-cgroup-container', - }, - 'cpu_energy_rapl_msr_component': { - 'clean_name': 'CPU Energy (Package)', - 'source': 'RAPL', - 'explanation': 'RAPL based CPU energy of package domain', - }, - 'cpu_power_rapl_msr_component': { - 'clean_name': 'CPU Power (Package)', - 'source': 'RAPL', - 'explanation': 'Derived RAPL based CPU energy of package domain', - }, - 'cpu_utilization_procfs_system': { - 'clean_name': 'CPU %', - 'source': 'procfs', - 'explanation': 'CPU Utilization of total system', - }, - 'memory_energy_rapl_msr_component': { - 'clean_name': 'Memory Energy (DRAM)', - 'source': 'RAPL', - 'explanation': 'RAPL based memory energy of DRAM domain', - }, - 'memory_power_rapl_msr_component': { - 'clean_name': 'Memory Power (DRAM)', - 'source': 'RAPL', - 'explanation': 'Derived RAPL based memory energy of DRAM domain', - }, - 'psu_co2_ac_sdia_machine': { - 'clean_name': 'Machine CO2', - 'source': 'Formula (SDIA)', - 'explanation': 'Machine CO2 calculated by formula via SDIA estimation', - }, - - 'psu_energy_ac_sdia_machine': { - 'clean_name': 'Machine Energy', - 'source': 'SDIA', - 'explanation': 'Full machine energy (AC) as estimated by SDIA model', - }, - - 'psu_power_ac_sdia_machine': { - 'clean_name': 'Machine Power', - 'source': 'SDIA', - 'explanation': 'Full machine power (AC) as estimated by SDIA model', - }, -} - - def rescale_energy_value(value, unit): # We only expect values to be mJ for energy! if unit != 'mJ' and not unit.startswith('ugCO2e/'): @@ -259,7 +46,7 @@ def is_valid_uuid(val): except ValueError: return False -def sanitize(item): +def html_escape_multi(item): """Replace special characters "'", "\"", "&", "<" and ">" to HTML-safe sequences.""" if item is None: return None @@ -268,17 +55,17 @@ def sanitize(item): return html_escape(item) if isinstance(item, list): - return [sanitize(element) for element in item] + return [html_escape_multi(element) for element in item] if isinstance(item, dict): for key, value in item.items(): if isinstance(value, str): item[key] = html_escape(value) elif isinstance(value, dict): - item[key] = sanitize(value) + item[key] = html_escape_multi(value) elif isinstance(value, list): item[key] = [ - sanitize(item) + html_escape_multi(item) if isinstance(item, dict) else html_escape(item) if isinstance(item, str) @@ -294,11 +81,113 @@ def sanitize(item): # This could cause an error if we ever make a BaseModel that has keys that begin with model_ keys = [key for key in dir(item_copy) if not key.startswith('_') and not key.startswith('model_') and not callable(getattr(item_copy, key))] for key in keys: - setattr(item_copy, key, sanitize(getattr(item_copy, key))) + setattr(item_copy, key, html_escape_multi(getattr(item_copy, key))) return item_copy return item +def get_machine_list(): + query = """ + SELECT id, description, available + FROM machines + ORDER BY description ASC + """ + return DB().fetch_all(query) + +def get_project_info(project_id): + query = """ + SELECT + id, name, uri, branch, commit_hash, + (SELECT STRING_AGG(t.name, ', ' ) FROM unnest(projects.categories) as elements + LEFT JOIN categories as t on t.id = elements) as categories, + filename, start_measurement, end_measurement, + measurement_config, machine_specs, machine_id, usage_scenario, + last_run, created_at, invalid_project, phases, logs + FROM projects + WHERE id = %s + """ + params = (project_id,) + return DB().fetch_one(query, params=params, row_factory=psycopg.rows.dict_row) + + +def get_timeline_query(uri,filename,machine_id, branch, metrics, phase, start_date=None, end_date=None, detail_name=None, limit_365=False, sorting='run'): + + if filename is None or filename.strip() == '': + filename = 'usage_scenario.yml' + + params = [uri, filename, machine_id] + + branch_condition = '' + if branch is not None and branch.strip() != '': + branch_condition = 'AND projects.branch = %s' + params.append(branch) + + metrics_condition = '' + if metrics is None or metrics.strip() == '' or metrics.strip() == 'key': + metrics_condition = "AND (metric LIKE '%%_energy_%%' OR metric = 'software_carbon_intensity_global')" + elif metrics.strip() != 'all': + metrics_condition = "AND metric = %s" + params.append(metrics) + + phase_condition = '' + if phase is not None and phase.strip() != '': + phase_condition = "AND (phase LIKE %s)" + params.append(f"%{phase}") + + start_date_condition = '' + if start_date is not None and start_date.strip() != '': + start_date_condition = "AND DATE(projects.last_run) >= TO_DATE(%s, 'YYYY-MM-DD')" + params.append(start_date) + + end_date_condition = '' + if end_date is not None and end_date.strip() != '': + end_date_condition = "AND DATE(projects.last_run) <= TO_DATE(%s, 'YYYY-MM-DD')" + params.append(end_date) + + detail_name_condition = '' + if detail_name is not None and detail_name.strip() != '': + detail_name_condition = "AND phase_stats.detail_name = %s" + params.append(detail_name) + + limit_365_condition = '' + if limit_365: + limit_365_condition = "AND projects.last_run >= CURRENT_DATE - INTERVAL '365 days'" + + sorting_condition = 'projects.commit_timestamp ASC, projects.last_run ASC' + if sorting is not None and sorting.strip() == 'run': + sorting_condition = 'projects.last_run ASC, projects.commit_timestamp ASC' + + + query = f""" + SELECT + projects.id, projects.name, projects.last_run, phase_stats.metric, phase_stats.detail_name, phase_stats.phase, + phase_stats.value, phase_stats.unit, projects.commit_hash, projects.commit_timestamp, + row_number() OVER () AS row_num + FROM projects + LEFT JOIN phase_stats ON + projects.id = phase_stats.project_id + WHERE + projects.uri = %s + AND projects.filename = %s + AND projects.end_measurement IS NOT NULL + AND projects.last_run IS NOT NULL + AND machine_id = %s + {metrics_condition} + {branch_condition} + {phase_condition} + {start_date_condition} + {end_date_condition} + {detail_name_condition} + {limit_365_condition} + AND projects.commit_timestamp IS NOT NULL + ORDER BY + phase_stats.metric ASC, phase_stats.detail_name ASC, + phase_stats.phase ASC, {sorting_condition} + + """ + print(query) + return (query, params) + def determine_comparison_case(ids): query = ''' @@ -314,7 +203,7 @@ def determine_comparison_case(ids): ''' data = DB().fetch_one(query, (ids, )) - if data is None or data == []: + if data is None or data == [] or data[1] is None: # special check for data[1] as this is aggregate query which always returns result raise RuntimeError('Could not determine compare case') [repos, usage_scenarios, machine_ids, commit_hashes, branches] = data @@ -546,11 +435,8 @@ def get_phase_stats_object(phase_stats, case): if metric_name not in phase_stats_object['data'][phase]: phase_stats_object['data'][phase][metric_name] = { - 'clean_name': METRIC_MAPPINGS[metric_name]['clean_name'], - 'explanation': METRIC_MAPPINGS[metric_name]['explanation'], 'type': metric_type, 'unit': unit, - 'source': METRIC_MAPPINGS[metric_name]['source'], #'mean': None, # currently no use for that #'stddev': None, # currently no use for that #'ci': None, # currently no use for that diff --git a/frontend/ci.html b/frontend/ci.html index 3b047c8cc..de275557a 100644 --- a/frontend/ci.html +++ b/frontend/ci.html @@ -33,7 +33,7 @@

CI Run Info

- - +
@@ -91,7 +90,7 @@

General Info

- +
diff --git a/frontend/compare.html b/frontend/compare.html index 08789a528..d887a17a8 100644 --- a/frontend/compare.html +++ b/frontend/compare.html @@ -35,7 +35,7 @@

Comparison of runs in repo

-
diff --git a/frontend/css/green-coding.css b/frontend/css/green-coding.css index b03199f83..abe2ba76f 100644 --- a/frontend/css/green-coding.css +++ b/frontend/css/green-coding.css @@ -13,7 +13,7 @@ Thanks to https://css-tricks.com/transitions-only-after-page-load/ */ -o-transition: none !important; } -#horizontal-card { +.full-width-card { width: 100% !important; margin-bottom: 26px !important; } diff --git a/frontend/js/helpers/charts.js b/frontend/js/helpers/charts.js index b7dc5ca58..6e17174b8 100644 --- a/frontend/js/helpers/charts.js +++ b/frontend/js/helpers/charts.js @@ -88,8 +88,25 @@ const getCompareChartOptions = (legend, series, chart_type='line', x_axis='time' return options; } +const calculateMA = (series, factor) => { + var result = []; + factor = Math.round(factor) + + for (let i = 0, len = series.length; i < len; i++) { + if (i < factor) { + result.push('-'); + continue; + } + var sum = 0; + for (var j = 0; j < factor; j++) { + sum += series[i - j].value; + } + result.push(sum / factor); + } + return result; +} -const getLineBarChartOptions = (legend, series, x_axis_name=null, y_axis_name='', x_axis='time', mark_area=null, no_toolbox=false, graphic=null, moving_average=false, show_x_axis_label=true) => { +const getLineBarChartOptions = (legend, labels, series, x_axis_name=null, y_axis_name='', x_axis='time', mark_area=null, no_toolbox=false, graphic=null, moving_average=false, show_x_axis_label=true) => { if(Object.keys(series).length == 0) { return {graphic: getChartGraphic("No energy reporter active")}; @@ -97,6 +114,20 @@ const getLineBarChartOptions = (legend, series, x_axis_name=null, y_axis_name='' let tooltip_trigger = (series[0].type=='line') ? 'axis' : 'item'; + if(moving_average) { + legend.push('Moving Average (5)') + series.push({ + name: 'Moving Average (5)', + type: 'line', + data: calculateMA(series[0].data, 5), + smooth: true, + lineStyle: { + opacity: 1, + color: 'red' + } + }) + } + let options = { tooltip: { trigger: tooltip_trigger }, grid: { @@ -109,7 +140,7 @@ const getLineBarChartOptions = (legend, series, x_axis_name=null, y_axis_name='' name: x_axis_name, type: x_axis, splitLine: {show: false}, - data: legend, + data: labels, axisLabel: { show: show_x_axis_label, interval: 0, @@ -126,7 +157,7 @@ const getLineBarChartOptions = (legend, series, x_axis_name=null, y_axis_name='' graphic: graphic, legend: { data: legend, - bottom: 0, + top: 0, type: 'scroll' } }; @@ -143,7 +174,7 @@ const getLineBarChartOptions = (legend, series, x_axis_name=null, y_axis_name='' if (no_toolbox == false) { options['toolbox'] = { itemSize: 25, - top: 55, + top: 15, feature: { dataZoom: { yAxisIndex: 'none' @@ -286,7 +317,7 @@ function movers(e) { icons.classList.toggle("hide") } -const createChartContainer = (container, el) => { +const createChartContainer = (container, title) => { const chart_node = document.createElement("div") chart_node.classList.add("card"); chart_node.classList.add('statistics-chart-card') @@ -295,7 +326,7 @@ const createChartContainer = (container, el) => { chart_node.innerHTML = `
- ${el} + ${title}
@@ -303,6 +334,7 @@ const createChartContainer = (container, el) => {
+
@@ -338,14 +370,14 @@ const displayKeyMetricsRadarChart = (legend, labels, data, phase) => { let options = { legend: { data: legend, - bottom: 0, + top: 0, type: 'scroll', }, radar: { shape: 'circle', indicator: labels, radius: '70%', - center: ['50%', '45%'], // Adjust the vertical position (y-axis) as needed + center: ['50%', '55%'], // Adjust the vertical position (y-axis) as needed }, series: [ { @@ -377,7 +409,7 @@ const displayKeyMetricsBarChart = (legend, labels, data, phase) => { document.querySelector(`.ui.tab[data-tab='${phase}'] .bar-chart .chart-title`).innerText = TOP_BAR_CHART_TITLE; let myChart = echarts.init(chartDom); - let options = getLineBarChartOptions(labels, series, null, TOP_BAR_CHART_UNIT, 'category', null, true); + let options = getLineBarChartOptions(null, labels, series, null, TOP_BAR_CHART_UNIT, 'category', null, true); myChart.setOption(options); // set callback when ever the user changes the viewport @@ -430,7 +462,7 @@ const displayKeyMetricsEmbodiedCarbonChart = (phase) => { data: ['N/A'] } ]; - let options = getLineBarChartOptions(['Phases'], series); + let options = getLineBarChartOptions(null, ['Phases'], series); myChart.setOption(options); // set callback when ever the user changes the viewport @@ -461,7 +493,7 @@ const displayTotalChart = (legend, labels, data) => { } - let options = getLineBarChartOptions(labels, series, null, TOTAL_CHART_UNIT, 'category', null, true) + let options = getLineBarChartOptions(null, labels, series, null, TOTAL_CHART_UNIT, 'category', null, true) myChart.setOption(options); // set callback when ever the user changes the viewport // we need to use jQuery here and not Vanilla JS to not overwrite but add multiple resize callbacks diff --git a/frontend/js/helpers/config.js.example b/frontend/js/helpers/config.js.example index 3f8bf7b50..500e95520 100644 --- a/frontend/js/helpers/config.js.example +++ b/frontend/js/helpers/config.js.example @@ -7,6 +7,234 @@ METRICS_URL = "__METRICS_URL__" The components are fixed, but you can rename then and include different metrics if needed */ +METRIC_MAPPINGS = { + + 'psu_co2_ac_mcp_machine': { + 'clean_name': 'Machine CO2', + 'source': 'mcp', + 'explanation': 'Machine CO2 as reported by mcp', + }, + + 'psu_energy_ac_mcp_machine': { + 'clean_name': 'Machine Energy', + 'source': 'mcp', + 'explanation': 'Full machine energy (AC) as reported by mcp', + }, + 'psu_power_ac_mcp_machine': { + 'clean_name': 'Machine Power', + 'source': 'mcp', + 'explanation': 'Full machine power (AC) as reported by PowerSpy2', + }, + + 'embodied_carbon_share_machine': { + 'clean_name': 'Embodied Carbon', + 'source': 'formula', + 'explanation': 'Embodied carbon attributed by time share of the life-span and total embodied carbon', + }, + 'software_carbon_intensity_global': { + 'clean_name': 'SCI', + 'source': 'formula', + 'explanation': 'SCI metric by the Green Software Foundation', + }, + 'phase_time_syscall_system': { + 'clean_name': 'Phase Duration', + 'source': 'Syscall', + 'explanation': 'Duration of the phase measured by GMT through a syscall', + }, + 'psu_co2_ac_ipmi_machine': { + 'clean_name': 'Machine CO2', + 'source': 'Formula (IPMI)', + 'explanation': 'Machine CO2 calculated by formula via IPMI measurement', + }, + 'psu_co2_dc_picolog_mainboard': { + 'clean_name': 'Machine CO2', + 'source': 'Formula (PicoLog)', + 'explanation': 'Machine CO2 calculated by formula via PicoLog HRDL ADC-24 measurement', + }, + 'psu_co2_ac_powerspy2_machine': { + 'clean_name': 'Machine CO2', + 'source': 'PowerSpy2', + 'explanation': 'Machine CO2 calculated by formula via PowerSpy2 measurement', + }, + 'psu_co2_ac_xgboost_machine': { + 'clean_name': 'Machine CO2', + 'source': 'Formula (XGBoost)', + 'explanation': 'Machine CO2 calculated by formula via XGBoost estimation', + }, + 'network_energy_formula_global': { + 'clean_name': 'Network Energy', + 'source': 'Formula', + 'explanation': 'Network Energy calculated by formula', + }, + 'network_co2_formula_global': { + 'clean_name': 'Network CO2', + 'source': 'Formula', + 'explanation': 'Network CO2 calculated by formula', + }, + 'lm_sensors_temperature_component': { + 'clean_name': 'CPU Temperature', + 'source': 'lm_sensors', + 'explanation': 'CPU Temperature as reported by lm_sensors', + }, + 'lm_sensors_fan_component': { + 'clean_name': 'Fan Speed', + 'source': 'lm_sensors', + 'explanation': 'Fan speed as reported by lm_sensors', + }, + 'psu_energy_ac_powerspy2_machine': { + 'clean_name': 'Machine Energy', + 'source': 'PowerSpy2', + 'explanation': 'Full machine energy (AC) as reported by PowerSpy2', + }, + 'psu_power_ac_powerspy2_machine': { + 'clean_name': 'Machine Power', + 'source': 'PowerSpy2', + 'explanation': 'Full machine power (AC) as reported by PowerSpy2', + }, + 'psu_energy_ac_xgboost_machine': { + 'clean_name': 'Machine Energy', + 'source': 'XGBoost', + 'explanation': 'Full machine energy (AC) as estimated by XGBoost model', + }, + 'psu_power_ac_xgboost_machine': { + 'clean_name': 'Machine Power', + 'source': 'XGBoost', + 'explanation': 'Full machine power (AC) as estimated by XGBoost model', + }, + 'psu_energy_ac_ipmi_machine': { + 'clean_name': 'Machine Energy', + 'source': 'IPMI', + 'explanation': 'Full machine energy (AC) as reported by IPMI', + }, + 'psu_power_ac_ipmi_machine': { + 'clean_name': 'Machine Power', + 'source': 'IPMI', + 'explanation': 'Full machine power (AC) as reported by IPMI', + }, + 'psu_energy_dc_picolog_mainboard': { + 'clean_name': 'Machine Energy', + 'source': 'PicoLog', + 'explanation': 'Full machine energy (DC) as reported by PicoLog HRDL ADC-24', + }, + 'psu_power_dc_picolog_mainboard': { + 'clean_name': 'Machine Power', + 'source': 'Picolog', + 'explanation': 'Full machine power (DC) as reported by PicoLog HRDL ADC-24', + }, + 'cpu_frequency_sysfs_core': { + 'clean_name': 'CPU Frequency', + 'source': 'sysfs', + 'explanation': 'CPU Frequency per core as reported by sysfs', + }, + 'ane_power_powermetrics_component': { + 'clean_name': 'ANE Power', + 'source': 'powermetrics', + 'explanation': 'Apple Neural Engine', + }, + 'ane_energy_powermetrics_component': { + 'clean_name': 'ANE Energy', + 'source': 'powermetrics', + 'explanation': 'Apple Neural Engine', + }, + 'gpu_power_powermetrics_component': { + 'clean_name': 'GPU Power', + 'source': 'powermetrics', + 'explanation': 'Apple M1 GPU / Intel GPU', + }, + 'gpu_energy_powermetrics_component': { + 'clean_name': 'GPU Energy', + 'source': 'powermetrics', + 'explanation': 'Apple M1 GPU / Intel GPU', + }, + 'cores_power_powermetrics_component': { + 'clean_name': 'CPU Power (Cores)', + 'source': 'powermetrics', + 'explanation': 'Power of the cores only without GPU, ANE, GPU, DRAM etc.', + }, + 'cores_energy_powermetrics_component': { + 'clean_name': 'CPU Energy (Cores)', + 'source': 'powermetrics', + 'explanation': 'Energy of the cores only without GPU, ANE, GPU, DRAM etc.', + }, + 'cpu_time_powermetrics_vm': { + 'clean_name': 'CPU time', + 'source': 'powermetrics', + 'explanation': 'Effective execution time of the CPU for all cores combined', + }, + 'disk_io_bytesread_powermetrics_vm': { + 'clean_name': 'Bytes read (HDD/SDD)', + 'source': 'powermetrics', + 'explanation': 'Effective execution time of the CPU for all cores combined', + }, + 'disk_io_byteswritten_powermetrics_vm': { + 'clean_name': 'Bytes written (HDD/SDD)', + 'source': 'powermetrics', + 'explanation': 'Effective execution time of the CPU for all cores combined', + }, + 'energy_impact_powermetrics_vm': { + 'clean_name': 'Energy impact', + 'source': 'powermetrics', + 'explanation': 'macOS proprietary value for relative energy impact on device', + }, + 'cpu_utilization_cgroup_container': { + 'clean_name': 'CPU %', + 'source': 'cgroup', + 'explanation': 'CPU Utilization per container', + }, + 'memory_total_cgroup_container': { + 'clean_name': 'Memory Usage', + 'source': 'cgroup', + 'explanation': 'Memory Usage per container', + }, + 'network_io_cgroup_container': { + 'clean_name': 'Network I/O', + 'source': 'cgroup', + 'explanation': 'Network I/O. Details on docs.green-coding.berlin/docs/measuring/metric-providers/network-io-cgroup-container', + }, + 'cpu_energy_rapl_msr_component': { + 'clean_name': 'CPU Energy (Package)', + 'source': 'RAPL', + 'explanation': 'RAPL based CPU energy of package domain', + }, + 'cpu_power_rapl_msr_component': { + 'clean_name': 'CPU Power (Package)', + 'source': 'RAPL', + 'explanation': 'Derived RAPL based CPU energy of package domain', + }, + 'cpu_utilization_procfs_system': { + 'clean_name': 'CPU %', + 'source': 'procfs', + 'explanation': 'CPU Utilization of total system', + }, + 'memory_energy_rapl_msr_component': { + 'clean_name': 'Memory Energy (DRAM)', + 'source': 'RAPL', + 'explanation': 'RAPL based memory energy of DRAM domain', + }, + 'memory_power_rapl_msr_component': { + 'clean_name': 'Memory Power (DRAM)', + 'source': 'RAPL', + 'explanation': 'Derived RAPL based memory energy of DRAM domain', + }, + 'psu_co2_ac_sdia_machine': { + 'clean_name': 'Machine CO2', + 'source': 'Formula (SDIA)', + 'explanation': 'Machine CO2 calculated by formula via SDIA estimation', + }, + + 'psu_energy_ac_sdia_machine': { + 'clean_name': 'Machine Energy', + 'source': 'SDIA', + 'explanation': 'Full machine energy (AC) as estimated by SDIA model', + }, + + 'psu_power_ac_sdia_machine': { + 'clean_name': 'Machine Power', + 'source': 'SDIA', + 'explanation': 'Full machine power (AC) as estimated by SDIA model', + }, +} + // title and filter function for the top left most chart in the Detailed Metrics / Compare view const TOTAL_CHART_BOTTOM_TITLE = 'Total energy consumption'; const TOTAL_CHART_BOTTOM_LABEL = 'Machine Energy'; diff --git a/frontend/js/helpers/main.js b/frontend/js/helpers/main.js index 32df06e54..a623e7396 100644 --- a/frontend/js/helpers/main.js +++ b/frontend/js/helpers/main.js @@ -76,7 +76,7 @@ const showNotification = (message_title, message_text, type='warning') => { const copyToClipboard = (e) => { if (navigator && navigator.clipboard && navigator.clipboard.writeText) - return navigator.clipboard.writeText(e.currentTarget.closest('div.inline.field').querySelector('span').innerHTML) + return navigator.clipboard.writeText(e.currentTarget.closest('.field').querySelector('span').innerHTML) alert('Copying badge on local is not working due to browser security models') return Promise.reject('The Clipboard API is not available.'); @@ -90,7 +90,7 @@ const dateToYMD = (date, short=false) => { let offset = date.getTimezoneOffset(); offset = offset < 0 ? `+${-offset/60}` : -offset/60; - if(short) return `${date.getFullYear().toString().substr(-2)}.${month}.${day}`; + if(short) return `${date.getFullYear().toString()}.${month}.${day}`; return ` ${date.getFullYear()}-${month}-${day}
${hours}:${minutes} UTC${offset}`; } diff --git a/frontend/js/helpers/metric-boxes.js b/frontend/js/helpers/metric-boxes.js index bed8ba641..d5d245035 100644 --- a/frontend/js/helpers/metric-boxes.js +++ b/frontend/js/helpers/metric-boxes.js @@ -184,7 +184,7 @@ customElements.define('phase-metrics', PhaseMetrics); /* TODO: Include one sided T-test? */ -const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_name, detail_data) => { +const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_name, detail_data, comparison_case) => { let max_value = '' if (detail_data.max != null) { let [max,max_unit] = convertValue(detail_data.max, metric_data.unit); @@ -222,11 +222,10 @@ const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_name, de let [value, unit] = convertValue(detail_data.mean, metric_data.unit); let tr = document.querySelector(`div.tab[data-tab='${phase}'] table.compare-metrics-table tbody`).insertRow(); - - if(detail_data.stddev != null) { + if(comparison_case !== null) { tr.innerHTML = ` - ${metric_data.clean_name} - ${metric_data.source} + ${METRIC_MAPPINGS[metric_name]['clean_name']} + ${METRIC_MAPPINGS[metric_name]['source']} ${scope} ${detail_name} ${metric_data.type} @@ -240,8 +239,8 @@ const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_name, de } else { tr.innerHTML = ` - ${metric_data.clean_name} - ${metric_data.source} + ${METRIC_MAPPINGS[metric_name]['clean_name']} + ${METRIC_MAPPINGS[metric_name]['source']} ${scope} ${detail_name} ${metric_data.type} @@ -253,9 +252,9 @@ const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_name, de updateKeyMetric( - phase, metric_name, metric_data.clean_name, detail_name, + phase, metric_name, METRIC_MAPPINGS[metric_name]['clean_name'], detail_name, value , std_dev_text, unit, - metric_data.explanation, metric_data.source + METRIC_MAPPINGS[metric_name]['explanation'], METRIC_MAPPINGS[metric_name]['source'] ); } @@ -298,8 +297,8 @@ const displayDiffMetricBox = (phase, metric_name, metric_data, detail_name, deta let tr = document.querySelector(`div.tab[data-tab='${phase}'] table.compare-metrics-table tbody`).insertRow(); tr.innerHTML = ` - ${metric_data.clean_name} - ${metric_data.source} + ${METRIC_MAPPINGS[metric_name]['clean_name']} + ${METRIC_MAPPINGS[metric_name]['source']} ${scope} ${detail_name} ${metric_data.type} @@ -310,9 +309,9 @@ const displayDiffMetricBox = (phase, metric_name, metric_data, detail_name, deta ${extra_label}`; updateKeyMetric( - phase, metric_name, metric_data.clean_name, detail_name, + phase, metric_name, METRIC_MAPPINGS[metric_name]['clean_name'], detail_name, value, '', metric_data.unit, - metric_data.explanation, metric_data.source + METRIC_MAPPINGS[metric_name]['explanation'], METRIC_MAPPINGS[metric_name]['source'] ); } @@ -359,21 +358,21 @@ const updateKeyMetric = (phase, metric_name, clean_name, detail_name, value, std let selector = null; // key metrics are already there, cause we want a fixed order, so we just replace - if(machine_energy_metric_condition(metric)) { + if(machine_energy_metric_condition(metric_name)) { selector = '.machine-energy'; - } else if(network_energy_metric_condition(metric)) { + } else if(network_energy_metric_condition(metric_name)) { selector = '.network-energy'; - } else if(phase_time_metric_condition(metric)) { + } else if(phase_time_metric_condition(metric_name)) { selector = '.phase-duration'; - } else if(network_co2_metric_condition(metric)) { + } else if(network_co2_metric_condition(metric_name)) { selector = '.network-co2'; - } else if(embodied_carbon_share_metric_condition(metric)) { + } else if(embodied_carbon_share_metric_condition(metric_name)) { selector = '.embodied-carbon'; - } else if(sci_metric_condition(metric)) { + } else if(sci_metric_condition(metric_name)) { selector = '.software-carbon-intensity'; - } else if(machine_power_metric_condition(metric)) { + } else if(machine_power_metric_condition(metric_name)) { selector = '.machine-power'; - } else if(machine_co2_metric_condition(metric)) { + } else if(machine_co2_metric_condition(metric_name)) { selector = '.machine-co2'; } else { return; // could not match key metric diff --git a/frontend/js/helpers/phase-stats.js b/frontend/js/helpers/phase-stats.js index cf595f4a8..3ad39bf36 100644 --- a/frontend/js/helpers/phase-stats.js +++ b/frontend/js/helpers/phase-stats.js @@ -131,38 +131,38 @@ const displayComparisonMetrics = (phase_stats_object) => { let found_bottom_chart_metric = false; const bottom_chart_present_keys = Object.fromEntries(phase_stats_object.comparison_details.map(e => [e, false])) - for (metric in phase_data) { - let metric_data = phase_data[metric] + for (metric_name in phase_data) { + let metric_data = phase_data[metric_name] let found_radar_chart_item = false; - for (detail in metric_data['data']) { - let detail_data = metric_data['data'][detail] + for (detail_name in metric_data['data']) { + let detail_data = metric_data['data'][detail_name] /* BLOCK LABELS This block must be done outside of the key loop and cannot use a Set() datastructure as we can have the same metric multiple times just with different detail names */ - if(radar_chart_condition(metric) && phase_stats_object.comparison_details.length >= 2) { - radar_chart_labels.push(metric_data.clean_name); + if(radar_chart_condition(metric_name) && phase_stats_object.comparison_details.length >= 2) { + radar_chart_labels.push(METRIC_MAPPINGS[metric_name]['clean_name']); } - if (top_bar_chart_condition(metric)) { - top_bar_chart_labels.push(`${metric_data.clean_name} (${metric_data.source})`); + if (top_bar_chart_condition(metric_name)) { + top_bar_chart_labels.push(`${METRIC_MAPPINGS[metric_name]['clean_name']} (${METRIC_MAPPINGS[metric_name]['source']})`); } - if (total_chart_bottom_condition(metric)) { + if (total_chart_bottom_condition(metric_name)) { if(found_bottom_chart_metric) { - showWarning(phase, `Another metric for the bottom chart was already set (${found_bottom_chart_metric}), skipping ${metric} and only first one will be shown.`); + showWarning(phase, `Another metric for the bottom chart was already set (${found_bottom_chart_metric}), skipping ${metric_name} and only first one will be shown.`); } else { - total_chart_bottom_legend[phase].push(metric_data.clean_name); - found_bottom_chart_metric = `${metric} ${detail_data['name']}`; + total_chart_bottom_legend[phase].push(METRIC_MAPPINGS[metric_name]['clean_name']); + found_bottom_chart_metric = `${metric_name} ${detail_name}`; } } /* END BLOCK LABELS*/ if (Object.keys(detail_data['data']).length != phase_stats_object.comparison_details.length) { - showWarning(phase, `${metric} ${detail} was missing from at least one comparison.`); + showWarning(phase, `${metric_name} ${detail_name} was missing from at least one comparison.`); } let compare_chart_data = [] @@ -172,14 +172,14 @@ const displayComparisonMetrics = (phase_stats_object) => { // we loop over all keys that exist, not over the one that are present in detail_data['data'] phase_stats_object.comparison_details.forEach((key,key_index) => { - if(radar_chart_condition(metric) && phase_stats_object.comparison_details.length >= 2) { + if(radar_chart_condition(metric_name) && phase_stats_object.comparison_details.length >= 2) { radar_chart_data[key_index].push(detail_data['data'][key]?.mean) } - if (top_bar_chart_condition(metric)) { + if (top_bar_chart_condition(metric_name)) { top_bar_chart_data[key_index].push(detail_data['data'][key]?.mean) } - if (total_chart_bottom_condition(metric) && `${metric} ${detail_data['name']}` == found_bottom_chart_metric) { + if (total_chart_bottom_condition(metric_name) && `${metric_name} ${detail_name}` == found_bottom_chart_metric) { if(total_chart_bottom_data?.[`${TOTAL_CHART_BOTTOM_LABEL} - ${key}`] == null) { total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${key}`] = [] } @@ -187,7 +187,7 @@ const displayComparisonMetrics = (phase_stats_object) => { bottom_chart_present_keys[key] = true } - if (phase_stats_object.comparison_case == null && machine_co2_metric_condition(metric)) { + if (phase_stats_object.comparison_case == null && machine_co2_metric_condition(metric_name)) { if(co2_calculated) { showWarning(phase, 'CO2 was already calculated! Do you have multiple machine energy reporters set?'); } @@ -208,17 +208,17 @@ const displayComparisonMetrics = (phase_stats_object) => { if (phase_stats_object.comparison_details.length == 1) { // Note: key is still the set variable from the for loop earlier - displaySimpleMetricBox(phase,metric, metric_data, detail_data['name'], detail_data['data'][phase_stats_object.comparison_details[0]]); + displaySimpleMetricBox(phase, metric_name, metric_data, detail_name, detail_data['data'][phase_stats_object.comparison_details[0]], phase_stats_object.comparison_case); } else { displayDiffMetricBox( - phase, metric, metric_data, detail_data['name'], metric_box_data, + phase, metric_name, metric_data, detail_name, metric_box_data, detail_data.is_significant ); } if(phase_stats_object.comparison_case !== null) { // compare charts will display for everything apart stats.html displayCompareChart( phase, - `${metric_data.clean_name} (${detail})`, + `${METRIC_MAPPINGS[metric_name]['clean_name']} via ${METRIC_MAPPINGS[metric_name]['source']} - ${detail_name} `, metric_data.unit, compare_chart_labels, compare_chart_data, diff --git a/frontend/js/stats.js b/frontend/js/stats.js index c55537098..95074c191 100644 --- a/frontend/js/stats.js +++ b/frontend/js/stats.js @@ -222,7 +222,7 @@ const displayTimelineCharts = (metrics, notes) => { for ( metric_name in metrics) { - const element = createChartContainer("#chart-container", metric_name); + const element = createChartContainer("#chart-container", `${METRIC_MAPPINGS[metric_name]['clean_name']} via ${METRIC_MAPPINGS[metric_name]['source']} `); let legend = []; let series = []; @@ -262,7 +262,7 @@ const displayTimelineCharts = (metrics, notes) => { }); const chart_instance = echarts.init(element); - let options = getLineBarChartOptions(legend, series, 'Time', metrics[metric_name].converted_unit); + let options = getLineBarChartOptions(null, legend, series, 'Time', metrics[metric_name].converted_unit); chart_instance.setOption(options); chart_instances.push(chart_instance); diff --git a/frontend/js/timeline.js b/frontend/js/timeline.js new file mode 100644 index 000000000..93493a4d3 --- /dev/null +++ b/frontend/js/timeline.js @@ -0,0 +1,282 @@ +const getURLParams = () => { + const query_string = window.location.search; + const url_params = (new URLSearchParams(query_string)) + return url_params; +} + +let chart_instances = []; + +window.onresize = function() { // set callback when ever the user changes the viewport + chart_instances.forEach(chart_instance => { + chart_instance.resize(); + }) +} + + +const numberFormatter = new Intl.NumberFormat('en-US', { + style: 'decimal', // You can also use 'currency', 'percent', or 'unit' + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function* colorIterator() { + colors = [ + '#88a788', + '#e5786d', + '#baa5c3', + '#f2efe3', + '#3d704d', + ] + let currentIndex = 0; + + function getNextItem(colors) { + const currentItem = colors[currentIndex]; + currentIndex = (currentIndex + 1) % colors.length; + return currentItem; + } + while (true) { + yield(getNextItem(colors)) + } +} + +const generateColoredValues = (values) => { + const color_iterator = colorIterator() + let last_commit_hash = null + let color = null; + return values.map((value) => { + if(last_commit_hash != value.commit_hash) { + last_commit_hash = value.commit_hash + color = color_iterator.next().value + } + return {value: value.value, itemStyle: {color: color}} + }) +} + +const populateMachines = async () => { + + try { + const machines_select = document.querySelector('select[name="machine_id"]'); + + machines_data = (await makeAPICall('/v1/machines/')) + machines_data.data.forEach(machine => { + let newOption = new Option(machine[1],machine[0]); + machines_select.add(newOption,undefined); + }) + } catch (err) { + showNotification('Could not get machines', err); + } + + +} + +const fillInputsFromURL = () => { + let url_params = getURLParams(); + + if(url_params.get('uri') == null + || url_params.get('uri') == '' + || url_params.get('uri') == 'null') { + showNotification('No uri', 'uri parameter in URL is empty or not present. Did you follow a correct URL?'); + throw "Error"; + } + $('input[name="uri"]').val(escapeString(url_params.get('uri'))); + $('#uri').text(escapeString(url_params.get('uri'))); + + // all variables can be set via URL initially + if(url_params.get('branch') != null) { + $('input[name="branch"]').val(escapeString(url_params.get('branch'))); + $('#branch').text(escapeString(url_params.get('branch'))); + } + if(url_params.get('filename') != null) { + $('input[name="filename"]').val(escapeString(url_params.get('filename'))); + $('#filename').text(escapeString(url_params.get('filename'))); + } + if(url_params.get('machine_id') != null) { + $('select[name="machine_id"]').val(escapeString(url_params.get('machine_id'))); + $('#machine').text($('select[name="machine_id"] :checked').text()); + } + if(url_params.get('sorting') != null) $(`#sorting-${url_params.get('sorting')}`).prop('checked', true); + if(url_params.get('phase') != null) $(`#phase-${url_params.get('phase')}`).prop('checked', true); + if(url_params.get('metrics') != null) $(`#metrics-${url_params.get('metrics')}`).prop('checked', true); + + // these two need no escaping, as the date library will always produce a result + // it might fail parsing the date however + try { + if(url_params.get('start_date') != null) $('#rangestart').calendar({initialDate: url_params.get('start_date')}); + if(url_params.get('end_date') != null) $('#rangeend').calendar({initialDate: url_params.get('end_date')}); + } catch (err) { + console.log("Date parsing failed") + } +} + +const buildQueryParams = (skip_dates=false,metric_override=null,detail_name=null) => { + let api_url = `uri=${$('input[name="uri"]').val()}`; + + // however, the form takes precendence + if($('input[name="branch"]').val() !== '') api_url = `${api_url}&branch=${$('input[name="branch"]').val()}` + if($('input[name="sorting"]:checked').val() !== '') api_url = `${api_url}&sorting=${$('input[name="sorting"]:checked').val()}` + if($('input[name="phase"]:checked').val() !== '') api_url = `${api_url}&phase=${$('input[name="phase"]:checked').val()}` + if($('select[name="machine_id"]').val() !== '') api_url = `${api_url}&machine_id=${$('select[name="machine_id"]').val()}` + if($('input[name="filename"]').val() !== '') api_url = `${api_url}&filename=${$('input[name="filename"]').val()}` + + if(metric_override != null) api_url = `${api_url}&metrics=${metric_override}` + else if($('input[name="metrics"]:checked').val() !== '') api_url = `${api_url}&metrics=${$('input[name="metrics"]:checked').val()}` + + if(detail_name != null) api_url = `${api_url}&detail_name=${detail_name}` + + if (skip_dates) return api_url; + + if ($('input[name="start_date"]').val() != '') { + let start_date = dateToYMD(new Date($('input[name="start_date"]').val()), short=true); + api_url = `${api_url}&start_date=${start_date}` + } + + if ($('input[name="end_date"]').val() != '') { + let end_date = dateToYMD(new Date($('input[name="end_date"]').val()), short=true); + api_url = `${api_url}&end_date=${end_date}` + } + return api_url; +} + + +const loadCharts = async () => { + chart_instances = []; // reset + document.querySelector("#chart-container").innerHTML = ''; // reset + document.querySelector("#badge-container").innerHTML = ''; // reset + + const api_url = `/v1/timeline?${buildQueryParams()}`; + + try { + var phase_stats_data = (await makeAPICall(api_url)).data + } catch (err) { + showNotification('Could not get compare in-repo data from API', err); + } + + if (phase_stats_data == undefined) return; + + let legends = {}; + let series = {}; + + let pproject_id = null + + phase_stats_data.forEach( (data) => { + let [project_id, project_name, last_run, metric_name, detail_name, phase, value, unit, commit_hash, commit_timestamp] = data + + + if (series[`${metric_name} - ${detail_name}`] == undefined) { + series[`${metric_name} - ${detail_name}`] = {labels: [], values: [], notes: [], unit: unit, metric_name: metric_name, detail_name: detail_name} + } + + series[`${metric_name} - ${detail_name}`].labels.push(commit_timestamp) + series[`${metric_name} - ${detail_name}`].values.push({value: value, commit_hash: commit_hash}) + series[`${metric_name} - ${detail_name}`].notes.push({ + project_name: project_name, + last_run: last_run, + commit_timestamp: commit_timestamp, + commit_hash: commit_hash, + phase: phase, + project_id: project_id, + pproject_id: pproject_id, + }) + + pproject_id = project_id + }) + + for(my_series in series) { + let badge = ` +
+

` + document.querySelector("#badge-container").innerHTML += badge; + + + const element = createChartContainer("#chart-container", `${METRIC_MAPPINGS[series[my_series].metric_name]['clean_name']} via ${METRIC_MAPPINGS[series[my_series].metric_name]['source']} - ${series[my_series].detail_name} `); + + const chart_instance = echarts.init(element); + + const my_values = generateColoredValues(series[my_series].values); + + let data_series = [{ + name: my_series, + type: 'bar', + smooth: true, + symbol: 'none', + areaStyle: {}, + data: my_values, + markLine: { + precision: 4, // generally annoying that precision is by default 2. Wrong AVG if values are smaller than 0.001 and no autoscaling! + data: [ {type: "average",label: {formatter: "AVG:\n{c}"}}] + } + }] + + let options = getLineBarChartOptions([], series[my_series].labels, data_series, 'Time', series[my_series].unit, 'category', null, false, null, true, false); + + options.tooltip = { + trigger: 'item', + formatter: function (params, ticket, callback) { + if(params.componentType != 'series') return; // no notes for the MovingAverage + return `${series[params.seriesName].notes[params.dataIndex].project_name}
+ date: ${series[params.seriesName].notes[params.dataIndex].last_run}
+ metric_name: ${params.seriesName}
+ phase: ${series[params.seriesName].notes[params.dataIndex].phase}
+ value: ${numberFormatter.format(series[params.seriesName].values[params.dataIndex].value)}
+ commit_timestamp: ${series[params.seriesName].notes[params.dataIndex].commit_timestamp}
+ commit_hash: ${series[params.seriesName].notes[params.dataIndex].commit_hash}
+
+ Click to diff measurement with previous + `; + } + }; + + chart_instance.on('click', function (params) { + if(params.componentType != 'series') return; // no notes for the MovingAverage + window.open(`/compare.html?ids=${series[params.seriesName].notes[params.dataIndex].project_id},${series[params.seriesName].notes[params.dataIndex].pproject_id}`, '_blank'); + + }); + + + chart_instance.setOption(options); + chart_instances.push(chart_instance); + + } + + document.querySelectorAll(".copy-badge").forEach(el => { + el.addEventListener('click', copyToClipboard) + }) + document.querySelector('#api-loader')?.remove(); + setTimeout(function(){console.log("Resize"); window.dispatchEvent(new Event('resize'))}, 500); +} + +$(document).ready( (e) => { + (async () => { + $('.ui.secondary.menu .item').tab({childrenOnly: true, context: '.project-data-container'}); // activate tabs for project data + $('#rangestart').calendar({ + type: 'date', + endCalendar: $('#rangeend'), + initialDate: new Date((new Date()).setDate((new Date).getDate() -30)), + }); + $('#rangeend').calendar({ + type: 'date', + startCalendar: $('#rangestart'), + initialDate: new Date(), + }); + + await populateMachines(); + + $('#submit').on('click', function() { + loadCharts() + }); + fillInputsFromURL(); + loadCharts(); + })(); +}); + diff --git a/frontend/request.html b/frontend/request.html index 7dba3aba5..e77af87ee 100644 --- a/frontend/request.html +++ b/frontend/request.html @@ -29,7 +29,7 @@

Measure software on measurement cluster

-
+
INTRODUCTION
@@ -60,7 +60,7 @@

-
+
ADD NEW PROJECT
diff --git a/frontend/settings.html b/frontend/settings.html index 6de715adc..959ca99ca 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -27,7 +27,7 @@

Settings

-
+
@@ -59,7 +59,7 @@

- + \ No newline at end of file diff --git a/frontend/stats.html b/frontend/stats.html index c83829740..c1b168087 100644 --- a/frontend/stats.html +++ b/frontend/stats.html @@ -38,7 +38,7 @@

Detailed Metrics

-
@@ -330,7 +330,7 @@

Total Phases Data

-
+

Metric Charts

diff --git a/frontend/timeline.html b/frontend/timeline.html new file mode 100644 index 000000000..bbbbef040 --- /dev/null +++ b/frontend/timeline.html @@ -0,0 +1,257 @@ + + + + + + + + + + + + + Green Metrics Tool + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

Timeline View

+
+
+
+ +

What is Timeline View?

+

Timeline View shows all the measurements we have for a specific project over time.
This allows you to inspect if a software has an upward or downward trend in energy, memory, CO2 etc.

+

If you want to change the graphs you are seeing, please click on Customize. The badges will change on refresh.

+ +
+
+
+
+
+ + - +
+
+ + - +
+
+
+
+ + - +
+
+ + - +
+
+
+
+
+
+ +
+
+ Badge Info +
+

Badges are showing always the trend over the last 365 days and calculations will always be on the Runtime phase as well as calculated by sorting on commit timestamp.

+

Badges are cached and will only update once per day, even if the charts already show new data.

+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+ Will not affect badge +
+
+
+ +
+
+ + +
+
+
+
+ + +
+
+
+ Will not affect badge +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+
+ +
+ +
+ +
+
+ Graph Info +
+

Graphs show every measurement as a bar. We color the bars according to the commit timestamp and change the color for every new commit. After some time the color repeats ...

+
+
+
+
+
+
Waiting for API data
+
+

+
+
+
+
Waiting for API data
+
+

+
+
+
+
+
+ + \ No newline at end of file diff --git a/lib/utils.py b/lib/utils.py index 58cfba59f..252b3167c 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -20,7 +20,7 @@ def get_project_data(project_name): WHERE name = %s """ data = DB().fetch_one(query, (project_name, ), row_factory=psycopg.rows.dict_row) - if (data is None or data == []): + if data is None or data == []: return None return data diff --git a/test/api/test_api_helpers.py b/test/api/test_api_helpers.py index 654834ff3..27d166eb8 100644 --- a/test/api/test_api_helpers.py +++ b/test/api/test_api_helpers.py @@ -31,21 +31,21 @@ class CI_Measurement(BaseModel): duration: int -def test_sanitize_dict(): +def test_escape_dict(): messy_dict = {"link": 'Click me'} escaped_link = '<a href="http://www.github.com">Click me</a>' - sanitized = api_helpers.sanitize(messy_dict.copy()) + escaped = api_helpers.html_escape_multi(messy_dict.copy()) - assert sanitized['link'] == escaped_link + assert escaped['link'] == escaped_link -def test_sanitize_project(): +def test_escape_project(): messy_project = Project(name="test", url='testURL', email='testEmail', branch='', machine_id=0) escaped_name = 'test<?>' - sanitized = api_helpers.sanitize(messy_project.model_copy()) + escaped = api_helpers.html_escape_multi(messy_project.model_copy()) - assert sanitized.name == escaped_name + assert escaped.name == escaped_name -def test_sanitize_measurement(): +def test_escape_measurement(): measurement = CI_Measurement( value=123, unit='mJ', @@ -61,6 +61,6 @@ def test_sanitize_measurement(): duration=13, ) escaped_repo = 'link<some_place>' - sanitized = api_helpers.sanitize(measurement.model_copy()) + escaped = api_helpers.html_escape_multi(measurement.model_copy()) - assert sanitized.repo == escaped_repo + assert escaped.repo == escaped_repo diff --git a/tools/jobs.py b/tools/jobs.py index 9b9869be0..2a58b2343 100644 --- a/tools/jobs.py +++ b/tools/jobs.py @@ -54,7 +54,7 @@ def check_job_running(job_type, job_id): query = "SELECT FROM jobs WHERE running=true AND type=%s" params = (job_type,) data = DB().fetch_one(query, params=params) - if data is not None: + if data: # No email here, only debug error_helpers.log_error('Job was still running: ', job_type, job_id) sys.exit(1) # is this the right way to exit here? @@ -76,7 +76,7 @@ def get_project(project_id): LEFT JOIN machines AS m ON p.machine_id = m.id WHERE p.id = %s LIMIT 1""", (project_id, )) - if (data is None or data == []): + if data is None or data == []: raise RuntimeError(f"couldn't find project w/ id: {project_id}") return data @@ -181,7 +181,7 @@ def handle_job_exception(exce, p_id): p_id = None try: job = get_job(args.type) - if (job is None or job == []): + if job is None or job == []: print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 'No job to process. Exiting') sys.exit(0) p_id = job[2] diff --git a/tools/update_commit_data.py b/tools/update_commit_data.py new file mode 100644 index 000000000..bb4b3bc9d --- /dev/null +++ b/tools/update_commit_data.py @@ -0,0 +1,56 @@ +#pylint: disable=import-error,wrong-import-position + +# This script will update the commit_timestamp field in the database +# for old projects where only the commit_hash field was populated + + +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'lib')) + +import subprocess +from datetime import datetime +from db import DB + +if __name__ == '__main__': + + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument('uri', help='URI to search for in DB') + parser.add_argument('folder', help='Local git folder, where to get commit timestamp from') + + args = parser.parse_args() # script will exit if arguments not present + + data = DB().fetch_all( + """ + SELECT + id, commit_hash + FROM + projects + WHERE + uri = %s + AND commit_hash IS NOT NULL + """, params=(args.uri,)) + + if not data: + raise RuntimeError(f"No match found in DB for {args.uri}!") + + for row in data: + project_id = str(row[0]) + commit_hash = row[1] + commit_timestamp = subprocess.run( + ['git', 'show', '-s', row[1], '--format=%ci'], + check=True, + capture_output=True, + encoding='UTF-8', + cwd=args.folder + ) + commit_timestamp = commit_timestamp.stdout.strip("\n") + parsed_timestamp = datetime.strptime(commit_timestamp, "%Y-%m-%d %H:%M:%S %z") + + DB().query( + 'UPDATE projects SET commit_timestamp = %s WHERE id = %s', + params=(parsed_timestamp, project_id) + ) + print(parsed_timestamp)