diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 532ea10e3..b25c4c4a3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,16 +7,16 @@ version: 2 updates: - package-ecosystem: "pip" directory: "/" - target-branch: "dev" + target-branch: "main" schedule: interval: "daily" - package-ecosystem: "docker" directory: "/" - target-branch: "dev" + target-branch: "main" schedule: interval: "weekly" - package-ecosystem: "github-actions" directory: "/" - target-branch: "dev" + target-branch: "main" schedule: interval: "daily" diff --git a/.github/workflows/tests-bare-metal-dev.yml b/.github/workflows/tests-bare-metal-dev.yml deleted file mode 100644 index bfb9bef2e..000000000 --- a/.github/workflows/tests-bare-metal-dev.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Daily Test Run - Bare Metal - Dev Branch -run-name: Scheduled - DEV Branch -on: - schedule: - - cron: '0 */6 * * *' - workflow_dispatch: - -jobs: - run-tests-dev: - runs-on: self-hosted - permissions: - packages: write - contents: read - steps: - # - id: check-date - # if: ${{ github.event_name != 'workflow_dispatch' }} - # uses: green-coding-berlin/eco-ci-activity-checker@main - # with: - # repo: 'green-coding-berlin/green-metrics-tool' - # branch: 'dev' - # workflow-id: 45267390 - - # - if: ${{ github.event_name == 'workflow_dispatch' || steps.check-date.outputs.should_run == 'true'}} - - name: 'Checkout repository' - uses: actions/checkout@v3 - with: - ref: 'dev' - submodules: 'true' - - - name: Eco CI Energy Estimation - Initialize - uses: green-coding-berlin/eco-ci-energy-estimation@main - with: - task: start-measurement - - # - if: ${{ github.event_name == 'workflow_dispatch' || steps.check-date.outputs.should_run == 'true'}} - - name: 'Setup, Run, and Teardown Tests' - uses: ./.github/actions/gmt-pytest - with: - metrics-to-turn-off: 'Machine Sensors Debug MacOS' - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Eco CI Energy Estimation - Get Measurement - uses: green-coding-berlin/eco-ci-energy-estimation@main - with: - task: get-measurement - branch: dev - - - name: Eco CI Energy Estimation - End Measurement - uses: green-coding-berlin/eco-ci-energy-estimation@main - with: - task: display-results - branch: dev diff --git a/.github/workflows/tests-eco-ci-energy-estimation.yaml b/.github/workflows/tests-eco-ci-energy-estimation.yaml index c736043a6..e1a184d1c 100644 --- a/.github/workflows/tests-eco-ci-energy-estimation.yaml +++ b/.github/workflows/tests-eco-ci-energy-estimation.yaml @@ -4,7 +4,7 @@ on: workflow_dispatch: jobs: - run-tests-dev: + run-tests-main: runs-on: ubuntu-latest permissions: packages: write @@ -13,7 +13,7 @@ jobs: - name: 'Checkout repository' uses: actions/checkout@v3 with: - ref: 'dev' + ref: 'main' submodules: 'true' - name: Eco CI Energy Estimation - Initialize @@ -37,13 +37,13 @@ jobs: uses: green-coding-berlin/eco-ci-energy-estimation@testing with: task: get-measurement - branch: dev + branch: main - name: Eco CI Energy Estimation - End Measurement uses: green-coding-berlin/eco-ci-energy-estimation@testing with: task: display-results - branch: dev + branch: main diff --git a/.github/workflows/tests-vm-dev.yml b/.github/workflows/tests-vm-dev.yml deleted file mode 100644 index eb924308b..000000000 --- a/.github/workflows/tests-vm-dev.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Daily Test Run - Virtual Machine - Dev Branch -run-name: Scheduled - DEV Branch -on: - schedule: - - cron: '0 0 * * *' - workflow_dispatch: - -jobs: - run-tests-dev: - runs-on: ubuntu-latest - permissions: - packages: write - contents: read - steps: - - id: check-date - if: ${{ github.event_name != 'workflow_dispatch' }} - uses: green-coding-berlin/eco-ci-activity-checker@v1 - with: - branch: 'dev' - - - if: ${{ github.event_name == 'workflow_dispatch' || steps.check-date.outputs.should_run == 'true'}} - name: 'Checkout repository' - uses: actions/checkout@v3 - with: - ref: 'dev' - submodules: 'true' - - - name: Eco CI Energy Estimation - Initialize - uses: green-coding-berlin/eco-ci-energy-estimation@701b5f2f4ba601be587823cd0786f07cb6ae2ee6 - with: - task: start-measurement - - - if: ${{ github.event_name == 'workflow_dispatch' || steps.check-date.outputs.should_run == 'true'}} - name: 'Setup, Run, and Teardown Tests' - uses: ./.github/actions/gmt-pytest - with: - metrics-to-turn-off: '--categories RAPL Machine Sensors Debug CGroupV2 MacOS --providers CpuFrequencySysfsCoreProvider' - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Eco CI Energy Estimation - Get Measurement - uses: green-coding-berlin/eco-ci-energy-estimation@701b5f2f4ba601be587823cd0786f07cb6ae2ee6 - with: - task: get-measurement - branch: dev - - - name: Eco CI Energy Estimation - End Measurement - uses: green-coding-berlin/eco-ci-energy-estimation@701b5f2f4ba601be587823cd0786f07cb6ae2ee6 - with: - task: display-results - branch: dev - - - diff --git a/.github/workflows/tests-vm-main.yml b/.github/workflows/tests-vm-main.yml index e6c5b6db0..bc46f217f 100644 --- a/.github/workflows/tests-vm-main.yml +++ b/.github/workflows/tests-vm-main.yml @@ -26,7 +26,7 @@ jobs: submodules: 'true' - name: Eco CI Energy Estimation - Initialize - uses: green-coding-berlin/eco-ci-energy-estimation@v2 + uses: green-coding-berlin/eco-ci-energy-estimation@701b5f2f4ba601be587823cd0786f07cb6ae2ee6 with: task: start-measurement @@ -38,13 +38,13 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Eco CI Energy Estimation - Get Measurement - uses: green-coding-berlin/eco-ci-energy-estimation@v2 + uses: green-coding-berlin/eco-ci-energy-estimation@701b5f2f4ba601be587823cd0786f07cb6ae2ee6 with: task: get-measurement branch: main - name: Eco CI Energy Estimation - End Measurement - uses: green-coding-berlin/eco-ci-energy-estimation@v2 + uses: green-coding-berlin/eco-ci-energy-estimation@701b5f2f4ba601be587823cd0786f07cb6ae2ee6 with: task: display-results branch: main diff --git a/.github/workflows/tests-vm-pr.yml b/.github/workflows/tests-vm-pr.yml index 0d859c465..f16f01261 100644 --- a/.github/workflows/tests-vm-pr.yml +++ b/.github/workflows/tests-vm-pr.yml @@ -9,6 +9,7 @@ jobs: permissions: packages: write contents: read + pull-requests: write steps: - name: 'Checkout repository' uses: actions/checkout@v3 @@ -31,13 +32,12 @@ jobs: uses: green-coding-berlin/eco-ci-energy-estimation@v2 with: task: get-measurement - branch: dev + branch: main - name: Eco CI Energy Estimation - End Measurement uses: green-coding-berlin/eco-ci-energy-estimation@v2 with: task: display-results - branch: dev - - - + branch: main + pr-comment: true + \ No newline at end of file diff --git a/README.md b/README.md index 862a14cd7..811f18779 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ [![Tests Status - Main](https://github.com/green-coding-berlin/green-metrics-tool/actions/workflows/tests-vm-main.yml/badge.svg)](https://github.com/green-coding-berlin/green-metrics-tool/actions/workflows/tests-vm-main.yml) -[![Tests Status - Dev](https://github.com/green-coding-berlin/green-metrics-tool/actions/workflows/tests-vm-dev.yml/badge.svg)](https://github.com/green-coding-berlin/green-metrics-tool/actions/workflows/tests-vm-dev.yml) [![Energy Used](https://api.green-coding.berlin/v1/ci/badge/get/?repo=green-coding-berlin/green-metrics-tool&branch=dev&workflow=45267392)](https://metrics.green-coding.berlin/ci.html?repo=green-coding-berlin/green-metrics-tool&branch=dev&workflow=45267392) (This is the energy cost of running our CI-Pipelines on Github. [Find out more about Eco-CI](https://www.green-coding.berlin/projects/eco-ci/)) diff --git a/api/api.py b/api/api.py index 6a909aad9..56cfddec5 100644 --- a/api/api.py +++ b/api/api.py @@ -8,6 +8,7 @@ import sys import os +from xml.sax.saxutils import escape as xml_escape from fastapi import FastAPI, Request, Response, status from fastapi.responses import ORJSONResponse from fastapi.encoders import jsonable_encoder @@ -31,7 +32,6 @@ sanitize, get_phase_stats, get_phase_stats_object, is_valid_uuid, rescale_energy_value) - # It seems like FastAPI already enables faulthandler as it shows stacktrace on SEGFAULT # Is the redundant call problematic faulthandler.enable() # will catch segfaults and write to STDERR @@ -133,7 +133,7 @@ async def get_notes(project_id): @app.get('/v1/machines/') async def get_machines(): query = """ - SELECT id, description + SELECT id, description, available FROM machines ORDER BY description ASC """ @@ -146,14 +146,28 @@ async def get_machines(): # A route to return all of the available entries in our catalog. @app.get('/v1/projects') -async def get_projects(): +async def get_projects(repo: str, filename: str): query = """ SELECT a.id, a.name, a.uri, COALESCE(a.branch, 'main / master'), a.end_measurement, a.last_run, a.invalid_project, a.filename, b.description, a.commit_hash FROM projects as a LEFT JOIN machines as b on a.machine_id = b.id - ORDER BY a.created_at DESC -- important to order here, the charting library in JS cannot do that automatically! + WHERE 1=1 """ - data = DB().fetch_all(query) + params = [] + + filename = filename.strip() + if filename not in ('', 'null'): + query = f"{query} AND a.filename LIKE %s \n" + params.append(f"%{filename}%") + + repo = repo.strip() + if repo not in ('', 'null'): + query = f"{query} AND a.uri LIKE %s \n" + params.append(f"%{repo}%") + + query = f"{query} ORDER BY a.created_at DESC -- important to order here, the charting library in JS cannot do that automatically!" + + data = DB().fetch_all(query, params=tuple(params)) if data is None or data == []: return Response(status_code=204) # No-Content @@ -343,42 +357,46 @@ async def get_badge_single(project_id: str, metric: str = 'ml-estimated'): return ORJSONResponse({'success': False, 'err': 'Project ID is not a valid UUID or empty'}, status_code=400) query = ''' - WITH times AS ( - SELECT start_measurement, end_measurement FROM projects WHERE id = %s - ) SELECT - (SELECT start_measurement FROM times), (SELECT end_measurement FROM times), - SUM(measurements.value), measurements.unit - FROM measurements + SELECT + SUM(value), MAX(unit) + FROM + phase_stats WHERE - measurements.project_id = %s - AND measurements.time >= (SELECT start_measurement FROM times) - AND measurements.time <= (SELECT end_measurement FROM times) - AND measurements.metric LIKE %s - GROUP BY measurements.unit + project_id = %s + AND metric LIKE %s + AND phase LIKE '%%_[RUNTIME]' ''' value = None + label = 'Energy Cost' + via = '' if metric == 'ml-estimated': value = 'psu_energy_ac_xgboost_machine' + via = 'via XGBoost ML' elif metric == 'RAPL': - value = '%_rapl_%' + value = '%_energy_rapl_%' + via = 'via RAPL' elif metric == 'AC': value = 'psu_energy_ac_%' + via = 'via PSU (AC)' + elif metric == 'SCI': + label = 'SCI' + value = 'software_carbon_intensity_global' else: return ORJSONResponse({'success': False, 'err': f"Unknown metric '{metric}' submitted"}, status_code=400) - params = (project_id, project_id, value) + params = (project_id, value) data = DB().fetch_one(query, params=params) - if data is None or data == []: + if data is None or data == [] or not data[1] : badge_value = 'No energy data yet' else: - [energy_value, energy_unit] = rescale_energy_value(data[2], data[3]) - badge_value= f"{energy_value:.2f} {energy_unit} via {metric}" + [energy_value, energy_unit] = rescale_energy_value(data[0], data[1]) + badge_value= f"{energy_value:.2f} {energy_unit} {via}" badge = anybadge.Badge( - label='Energy cost', - value=badge_value, + label=xml_escape(label), + value=xml_escape(badge_value), num_value_padding_chars=1, default_color='cornflowerblue') return Response(content=str(badge), media_type="image/svg+xml") @@ -580,7 +598,7 @@ async def get_ci_badge_get(repo: str, branch: str, workflow:str): badge = anybadge.Badge( label='Energy Used', - value=badge_value, + value=xml_escape(badge_value), num_value_padding_chars=1, default_color='green') return Response(content=str(badge), media_type="image/svg+xml") diff --git a/api/api_helpers.py b/api/api_helpers.py index 57d131d57..14276adce 100644 --- a/api/api_helpers.py +++ b/api/api_helpers.py @@ -21,6 +21,17 @@ 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', @@ -223,17 +234,21 @@ def rescale_energy_value(value, unit): # We only expect values to be mJ for energy! - if unit != 'mJ': - raise RuntimeError('Unexpected unit occured for energy rescaling: ', unit) + if unit in ['mJ', 'ug'] or unit.startswith('ugCO2e/'): + unit_type = unit[1:] - energy_rescaled = [value, unit] + energy_rescaled = [value, unit] + + # pylint: disable=multiple-statements + if value > 1_000_000_000: energy_rescaled = [value/(10**12), f"G{unit_type}"] + elif value > 1_000_000_000: energy_rescaled = [value/(10**9), f"M{unit_type}"] + elif value > 1_000_000: energy_rescaled = [value/(10**6), f"k{unit_type}"] + elif value > 1_000: energy_rescaled = [value/(10**3), f"m{unit_type}"] + elif value < 0.001: energy_rescaled = [value*(10**3), f"n{unit_type}"] + + else: + raise RuntimeError('Unexpected unit occured for energy rescaling: ', unit) - # pylint: disable=multiple-statements - if value > 1_000_000_000: energy_rescaled = [value/(10**12), 'GJ'] - elif value > 1_000_000_000: energy_rescaled = [value/(10**9), 'MJ'] - elif value > 1_000_000: energy_rescaled = [value/(10**6), 'kJ'] - elif value > 1_000: energy_rescaled = [value/(10**3), 'J'] - elif value < 0.001: energy_rescaled = [value*(10**3), 'nJ'] return energy_rescaled @@ -309,13 +324,14 @@ def determine_comparison_case(ids): # these cannot be just averaged. But they have to be split and then compared via t-test # For the moment I think it makes sense to restrict to two repositories. Comparing three is too much to handle I believe if we do not want to drill down to one specific metric - # Currently we support five cases: + # Currently we support six cases: # case = 'Repository' # Case D : RequirementsEngineering Case # case = 'Branch' # Case C_3 : SoftwareDeveloper Case # case = 'Usage Scenario' # Case C_2 : SoftwareDeveloper Case # case = 'Machine' # Case C_1 : DataCenter Case # case = 'Commit' # Case B: DevOps Case # case = 'Repeated Run' # Case A: Blue Angel + # case = 'Multi-Commit' # Case D: Evolution of repo over time if repos == 2: # diff repos @@ -362,7 +378,7 @@ def determine_comparison_case(ids): if commit_hashes == 2: # same repo, same usage scenarios, same machines, diff commit hashes case = 'Commit' # Case B elif commit_hashes > 2: # same repo, same usage scenarios, same machines, many commit hashes - raise RuntimeError('Multiple Commits not supported. Please switch to Timeline view') + raise RuntimeError('Multiple commits comparison not supported. Please switch to Timeline view') else: # same repo, same usage scenarios, same machines, same branches, same commit hashes case = 'Repeated Run' # Case A else: # same repo, same usage scenarios, same machines, diff branch @@ -503,8 +519,7 @@ def get_phase_stats_object(phase_stats, case): phase_stats_object = { 'comparison_case': case, - 'comparison_details': [], - 'statistics': {}, + 'comparison_details': set(), 'data': {} } @@ -527,14 +542,10 @@ def get_phase_stats_object(phase_stats, case): else: key = commit_hash # No comparison case / Case A: Blue Angel / Case B: DevOps Case - if key not in phase_stats_object['data']: - phase_stats_object['data'][key] = {} - phase_stats_object['comparison_details'].append(key) + if phase not in phase_stats_object['data']: phase_stats_object['data'][phase] = {} - if phase not in phase_stats_object['data'][key]: phase_stats_object['data'][key][phase] = {} - - if metric_name not in phase_stats_object['data'][key][phase]: - phase_stats_object['data'][key][phase][metric_name] = { + 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, @@ -548,20 +559,43 @@ def get_phase_stats_object(phase_stats, case): 'data': {}, } - if detail_name not in phase_stats_object['data'][key][phase][metric_name]['data']: - phase_stats_object['data'][key][phase][metric_name]['data'][detail_name] = { + if detail_name not in phase_stats_object['data'][phase][metric_name]['data']: + phase_stats_object['data'][phase][metric_name]['data'][detail_name] = { 'name': detail_name, - 'mean': None, # this is the mean over all repetitions of the detail_name + # 'mean': None, # mean for a detail over multiple machines / branches makes no sense + # 'max': max_value, # max for a detail over multiple machines / branches makes no sense + # 'min': min_value, # min for a detail over multiple machines / branches makes no sense + # 'stddev': None, # stddev for a detail over multiple machines / branches makes no sense + # 'ci': None, # since we only compare two keys atm this could no be calculated. + 'p_value': None, # comparing the means of two machines, branches etc. Both cases must have multiple values for this to get populated + 'is_significant': None, # comparing the means of two machines, branches etc. Both cases must have multiple values for this to get populated + 'data': {}, + } + + detail_data = phase_stats_object['data'][phase][metric_name]['data'][detail_name]['data'] + if key not in detail_data: + detail_data[key] = { + 'mean': None, # this is the mean over all repetitions of the detail_name for the key 'max': max_value, 'min': min_value, + 'max_mean': None, + 'min_mean': None, 'stddev': None, 'ci': None, 'p_value': None, # only for the last key the list compare to the rest. one-sided t-test 'is_significant': None, # only for the last key the list compare to the rest. one-sided t-test 'values': [], } + phase_stats_object['comparison_details'].add(key) - phase_stats_object['data'][key][phase][metric_name]['data'][detail_name]['values'].append(value) + detail_data[key]['values'].append(value) + + # since we do not save the min/max values we need to to the comparison here in every loop again + # all other statistics are derived later in add_phase_stats_statistics() + detail_data[key]['max'] = max((x for x in [max_value, detail_data[key]['max']] if x is not None), default=None) + detail_data[key]['min'] = min((x for x in [min_value, detail_data[key]['min']] if x is not None), default=None) + + phase_stats_object['comparison_details'] = list(phase_stats_object['comparison_details']) return phase_stats_object @@ -569,72 +603,66 @@ def get_phase_stats_object(phase_stats, case): ''' Here we need to traverse the object again and calculate all the averages we need This could have also been done while constructing the object through checking when a change - in phase / detail_name etc. occurs. + in phase / detail_name etc. occurs., however this is more efficient ''' def add_phase_stats_statistics(phase_stats_object): - ## build per comparison key stats - for key in phase_stats_object['data']: - for phase, phase_data in phase_stats_object['data'][key].items(): - for metric_name, metric in phase_data.items(): - for detail_name, detail in metric['data'].items(): + for _, phase_data in phase_stats_object['data'].items(): + for _, metric in phase_data.items(): + for _, detail in metric['data'].items(): + for _, key_obj in detail['data'].items(): + # if a detail has multiple values we calculate a std.dev and the one-sided t-test for the last value - detail['mean'] = detail['values'][0] # default. might be overridden + key_obj['mean'] = key_obj['values'][0] # default. might be overridden + key_obj['max_mean'] = key_obj['values'][0] # default. might be overridden + key_obj['min_mean'] = key_obj['values'][0] # default. might be overridden - if len(detail['values']) > 1: - t_stat = get_t_stat(len(detail['values'])) + if len(key_obj['values']) > 1: + t_stat = get_t_stat(len(key_obj['values'])) # JSON does not recognize the numpy data types. Sometimes int64 is returned - detail['mean'] = float(np.mean(detail['values'])) - detail['stddev'] = float(np.std(detail['values'])) - detail['max'] = float(np.max(detail['values'])) # overwrite with max of list - detail['min'] = float(np.min(detail['values'])) # overwrite with min of list - detail['ci'] = detail['stddev']*t_stat - - if len(detail['values']) > 2: - data_c = detail['values'].copy() + key_obj['mean'] = float(np.mean(key_obj['values'])) + key_obj['stddev'] = float(np.std(key_obj['values'])) + key_obj['max_mean'] = np.max(key_obj['values']) # overwrite with max of list + key_obj['min_mean'] = np.min(key_obj['values']) # overwrite with min of list + key_obj['ci'] = key_obj['stddev']*t_stat + + if len(key_obj['values']) > 2: + data_c = key_obj['values'].copy() pop_mean = data_c.pop() _, p_value = scipy.stats.ttest_1samp(data_c, pop_mean) if not np.isnan(p_value): - detail['p_value'] = p_value - if detail['p_value'] > 0.05: - detail['is_significant'] = False + key_obj['p_value'] = p_value + if key_obj['p_value'] > 0.05: + key_obj['is_significant'] = False else: - detail['is_significant'] = True + key_obj['is_significant'] = True ## builds stats between the keys if len(phase_stats_object['comparison_details']) == 2: # since we currently allow only two comparisons we hardcode this here + # this is then needed to rebuild if we would allow more key1 = phase_stats_object['comparison_details'][0] key2 = phase_stats_object['comparison_details'][1] # we need to traverse only one branch of the tree like structure, as we only need to compare matching metrics - for phase, phase_data in phase_stats_object['data'][key1].items(): - phase_stats_object['statistics'][phase] = {} - for metric_name, metric in phase_data.items(): - phase_stats_object['statistics'][phase][metric_name] = {} - for detail_name, detail in metric['data'].items(): - phase_stats_object['statistics'][phase][metric_name][detail_name] = {} - try: # other metric or phase might not be present - detail2 = phase_stats_object['data'][key2][phase][metric_name]['data'][detail_name] - except KeyError: + for _, phase_data in phase_stats_object['data'].items(): + for _, metric in phase_data.items(): + for _, detail in metric['data'].items(): + if key1 not in detail['data'] or key2 not in detail['data']: continue - statistics_node = phase_stats_object['statistics'][phase][metric_name][detail_name] # Welch-Test because we cannot assume equal variances - _, p_value = scipy.stats.ttest_ind(detail['values'], detail2['values'], equal_var=False) # + _, p_value = scipy.stats.ttest_ind(detail['data'][key1]['values'], detail['data'][key2]['values'], equal_var=False) - if np.isnan(p_value): - statistics_node['p_value'] = None - statistics_node['is_significant'] = None - else: - statistics_node['p_value'] = p_value - if statistics_node['p_value'] > 0.05: - statistics_node['is_significant'] = False + if not np.isnan(p_value): + detail['p_value'] = p_value + if detail['p_value'] > 0.05: + detail['is_significant'] = False else: - statistics_node['is_significant'] = True + detail['is_significant'] = True return phase_stats_object diff --git a/config.yml.example b/config.yml.example index 7ae1018ae..b1170a447 100644 --- a/config.yml.example +++ b/config.yml.example @@ -27,6 +27,7 @@ admin: notify_admin_for_own_project_ready: False + cluster: api_url: __API_URL__ metrics_url: __METRICS_URL__ @@ -120,3 +121,31 @@ measurement: # HW_MemAmountGB: 16 # Hardware_Availability_Year: 2011 #--- END + + +sci: + # https://github.com/Green-Software-Foundation/sci/blob/main/Software_Carbon_Intensity/Software_Carbon_Intensity_Specification.md + + # The values specific to the machine will be set here. The values that are specific to the + # software, like R – Functional unit, will be set in the usage_scenario.yml + + # EL Expected Lifespan; the anticipated time that the equipment will be installed. Value is in years + # The number 3.5 comes from a typical developer machine (Apple Macbook 16" 2023 - https://dataviz.boavizta.org/manufacturerdata?lifetime=3.5&name=14-inch%20MacBook%20Pro%20with%2064GB) + EL: 3.5 + # RS Resource-share; the share of the total available resources of the hardware reserved for use by the software. + # This ratio is typically 1 with the Green Metrics Tool unless you use a custom distributed orchestrator + RS: 1 + # TE Total Embodied Emissions; the sum of Life Cycle Assessment (LCA) emissions for all hardware components. + # Value is in gCO2eq + # The value has to be identified from vendor datasheets. Here are some example sources: + # https://dataviz.boavizta.org/manufacturerdata + # https://tco.exploresurface.com/sustainability/calculator + # https://www.delltechnologies.com/asset/en-us/products/servers/technical-support/Full_LCA_Dell_R740.pdf + # The default is the value for a developer machine (Apple Macbook 16" 2023 - https://dataviz.boavizta.org/manufacturerdata?lifetime=3.5&name=14-inch%20MacBook%20Pro%20with%2064GB) + TE: 194000 + # I is the Carbon Intensity at the location of this machine + # The value can either be a number in gCO2e/kWh or a carbon intensity provider that fetches this number dynamically + # https://docs.green-coding.berlin/docs/measuring/carbon-intensity-providers/carbon-intensity-providers-overview/ + # For fixed values get the number from https://ember-climate.org/insights/research/global-electricity-review-2022/ + # The number 475 that comes as default is for Germany from 2022 + I: 475 \ No newline at end of file diff --git a/docker/Dockerfile-gunicorn b/docker/Dockerfile-gunicorn index 73bc24efc..2f77559c4 100644 --- a/docker/Dockerfile-gunicorn +++ b/docker/Dockerfile-gunicorn @@ -1,14 +1,8 @@ # syntax=docker/dockerfile:1 -FROM ubuntu:22.04 +FROM python:3.11.4-slim-bookworm ENV DEBIAN_FRONTEND=noninteractive -RUN rm -rf /var/lib/apt/lists/* -RUN apt update && \ - apt install python3 python3-pip gunicorn -y - COPY requirements.txt requirements.txt RUN pip3 install -r requirements.txt -RUN rm -rf /var/lib/apt/lists/* - -ENTRYPOINT ["/bin/gunicorn", "--workers=2", "--log-file=-", "--worker-tmp-dir=/dev/shm", "--threads=4", "--worker-class=gthread", "--bind", "unix:/tmp/green-coding-api.sock", "-m", "007", "--user", "www-data", "--chdir", "/var/www/green-metrics-tool/api", "-k", "uvicorn.workers.UvicornWorker", "api:app"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/gunicorn", "--workers=2", "--access-logfile=-", "--error-logfile=-", "--worker-tmp-dir=/dev/shm", "--threads=4", "--worker-class=gthread", "--bind", "unix:/tmp/green-coding-api.sock", "-m", "007", "--user", "www-data", "--chdir", "/var/www/green-metrics-tool/api", "-k", "uvicorn.workers.UvicornWorker", "api:app"] \ No newline at end of file diff --git a/docker/requirements.txt b/docker/requirements.txt index a6f34eef3..881b3955f 100644 --- a/docker/requirements.txt +++ b/docker/requirements.txt @@ -1,9 +1,10 @@ -psycopg[binary]==3.1.9 -fastapi==0.100.0 -uvicorn[standard]==0.23.1 +gunicorn==21.2.0 +psycopg[binary]==3.1.10 +fastapi==0.101.0 +uvicorn[standard]==0.23.2 pandas==2.0.3 PyYAML==6.0.1 anybadge==1.14.0 scipy==1.11.1 -orjson==3.9.2 -schema==0.7.5 \ No newline at end of file +orjson==3.9.3 +schema==0.7.5 diff --git a/frontend/css/green-coding.css b/frontend/css/green-coding.css index dee643bd5..b03199f83 100644 --- a/frontend/css/green-coding.css +++ b/frontend/css/green-coding.css @@ -160,6 +160,7 @@ a, text-overflow: ellipsis; overflow-wrap: normal; overflow: hidden; + white-space: nowrap; } .si-unit { diff --git a/frontend/index.html b/frontend/index.html index 4ab15ac55..61f7c9500 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -53,6 +53,13 @@

--> + + diff --git a/frontend/js/ci.js b/frontend/js/ci.js index 2ad6829de..90a904cc7 100644 --- a/frontend/js/ci.js +++ b/frontend/js/ci.js @@ -171,7 +171,7 @@ const getChartOptions = (runs, chart_element) => { const displayGraph = (runs) => { const element = createChartContainer("#chart-container", "run-energy", runs); - + const options = getChartOptions(runs, element); const chart_instance = echarts.init(element); @@ -241,26 +241,26 @@ const displayCITable = (runs, url_params) => { var run_link = '' if(source == 'github') { - run_link = `https://github.com/${url_params.get('repo')}/actions/runs/${run_id}`; + run_link = `https://github.com/${escapeString(url_params.get('repo'))}/actions/runs/${escapeString(run_id)}`; } else if (source == 'gitlab') { - run_link = `https://gitlab.com/${url_params.get('repo')}/-/pipelines/${run_id}` + run_link = `https://gitlab.com/${escapeString(url_params.get('repo'))}/-/pipelines/${escapeString(run_id)}` } - const run_link_node = `${run_id}` + const run_link_node = `${escapeString(run_id)}` const created_at = el[3] const label = el[4] const duration = el[7] - li_node.innerHTML = `\ - \ + li_node.innerHTML = `\ + \ \ - \ - \ - \ - `; + \ + \ + \ + `; document.querySelector("#ci-table").appendChild(li_node); }); $('table').tablesort(); @@ -318,17 +318,17 @@ $(document).ready((e) => { let repo_link = '' if(badges_data.data[0][8] == 'github') { - repo_link = `https://github.com/${url_params.get('repo')}`; + repo_link = `https://github.com/${escapeString(url_params.get('repo'))}`; } else if(badges_data.data[0][8] == 'gitlab') { - repo_link = `https://gitlab.com/${url_params.get('repo')}`; + repo_link = `https://gitlab.com/${escapeString(url_params.get('repo'))}`; } //${repo_link} - const repo_link_node = `${url_params.get('repo')}` + const repo_link_node = `${escapeString(url_params.get('repo'))}` document.querySelector('#ci-data').insertAdjacentHTML('afterbegin', ``) - document.querySelector('#ci-data').insertAdjacentHTML('afterbegin', ``) - document.querySelector('#ci-data').insertAdjacentHTML('afterbegin', ``) - + document.querySelector('#ci-data').insertAdjacentHTML('afterbegin', ``) + document.querySelector('#ci-data').insertAdjacentHTML('afterbegin', ``) + displayCITable(badges_data.data, url_params); chart_instance = displayGraph(badges_data.data) displayAveragesTable(badges_data.data) diff --git a/frontend/js/compare.js b/frontend/js/compare.js index 5e2c60f82..8709c4607 100644 --- a/frontend/js/compare.js +++ b/frontend/js/compare.js @@ -38,9 +38,7 @@ $(document).ready( (e) => { document.querySelector('#project-data-top').insertAdjacentHTML('beforeend', ``) }); - let multi_comparison = determineMultiComparison(phase_stats_data.comparison_case) - setupPhaseTabs(phase_stats_data, multi_comparison) - displayComparisonMetrics(phase_stats_data, phase_stats_data.comparison_case, multi_comparison) + displayComparisonMetrics(phase_stats_data) })(); }); diff --git a/frontend/js/helpers/charts.js b/frontend/js/helpers/charts.js index 345b6a547..33c02ecfd 100644 --- a/frontend/js/helpers/charts.js +++ b/frontend/js/helpers/charts.js @@ -1,186 +1,89 @@ const getCompareChartOptions = (legend, series, mark_area=null, x_axis='time', chart_type='line', graphic=null) => { let tooltip_trigger = (chart_type=='line') ? 'axis' : 'item'; - if (series.length > 1) { - let max = Math.round(Math.max(...series[0],...series[1])*1.2) - let options = { - tooltip: {trigger: tooltip_trigger}, - xAxis: [ - { - gridIndex: 0, + + let series_count = series.length; + let max = 0 + series.forEach(item => { + if(item == undefined) return; + max = Math.max(max, ...item) + }) + max = Math.round(max*1.2) + + let options = { + tooltip: {trigger: tooltip_trigger}, + xAxis: [], + yAxis: [], + areaStyle: {}, + grid: [], + series: [], + animation: false, + graphic: graphic, + legend: [], + } + + series.forEach((item, index) => { + options.xAxis.push( + { + gridIndex: index, type: x_axis, splitLine: {show: false}, - name: [legend[0]], - nameLocation: 'center', - axisLabel: { - show: false, - }, - }, - { - gridIndex: 1, - splitLine: {show: false}, - type: x_axis, - name: [legend[1]], + name: [legend[index]], nameLocation: 'center', - axisLabel: { - show: false, - }, - } - ], - yAxis: [ - { - gridIndex: 0, - splitLine: {show: true}, - max: max - }, - { - gridIndex: 1, - splitLine: {show: true}, - max: max - }, - ], - areaStyle: {}, - grid: [ - { - right: '60%', - type: 'value', - bottom: 30, - containLabel: false, - }, - { - left: '60%', - type: 'value', - bottom: 30, - containLabel: false, + axisLabel: {show: false}, } - ], - series: [ - { - type: chart_type, - data: series[0], - xAxisIndex: 0, - yAxisIndex:0, - 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: "Mean:\n{c}"}}] - } + ); - }, - { - type: chart_type, - data: series[1], - xAxisIndex: 1, - yAxisIndex:1, - 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: "Mean:\n{c}"}}] - } - } - ], - animation: false, - graphic: graphic, - legend: [ - { - show: false, - data: legend[0], - bottom: 0, - type: 'scroll', - }, - { - show: false, - data: legend[0], - bottom: 0, - type: 'scroll', - }, - ], - toolbox: { - itemSize: 25, - top: 55, - feature: { - dataZoom: { - yAxisIndex: 'none' - }, - restore: {} - } - }, - }; - if (mark_area != null) { - options['series'][0]['markArea'] = { - data: [ - [ - { name: mark_area[0].name, yAxis: mark_area[0].top }, // a name in one item is apprently enough ... - { yAxis: mark_area[0].bottom } - ] - ] - }; - options['series'][1]['markArea'] = { - data: [ - [ - { name: mark_area[1].name, yAxis: mark_area[1].top }, // a name in one item is apprently enough ... - { yAxis: mark_area[1].bottom } - ] - ] - }; - } - return options; - } else { - let max = Math.round(Math.max(...series[0])*1.2) - let options = { - tooltip: {trigger: tooltip_trigger}, - grid: { - left: '0%', - right: '0%', - bottom: 30, - containLabel: true - }, - xAxis: { - type: x_axis, - splitLine: {show: false}, - name: [legend[0]], - nameLocation: 'center', - axisLabel: { - show: false, - }, - }, - yAxis: { - type: 'value', - splitLine: {show: true} - }, - series: [{ - data:series[0], + options.yAxis.push( + { + gridIndex: index, + splitLine: {show: true}, + max: max, + axisLabel: {show: false}, + } + ); + options.grid.push( + { + left: `${10 + (90 / series_count) * index}%`, + right: `${100 - (10 + (90 / series_count) * (index+1))}%`, + type: 'value', + bottom: 30, + containLabel: false + } + ); + options.legend.push( + { + data: legend[index], + bottom: 0, + show: false, + type: 'scroll', + } + ); + options.series.push( + { type: chart_type, - markLine: { data: [ {type: "average",label: {formatter: "Mean:\n{c}"}}]} - }], - animation: false, - graphic: graphic, - legend: { - show: false, - data: legend, - bottom: 0, - type: 'scroll', - }, - toolbox: { - itemSize: 25, - top: 55, - feature: { - dataZoom: { - yAxisIndex: 'none' - }, - restore: {} + data: series[index], + xAxisIndex: index, + yAxisIndex:index, + 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: "Mean:\n{c}"}}] } - }, - }; + } + ); if (mark_area != null) { - options['series'][0]['markArea'] = { + options['series'][index]['markArea'] = { data: [ [ - { name: mark_area[0].name, yAxis: mark_area[0].top }, // a name in one item is apprently enough ... - { yAxis: mark_area[0].bottom } + { name: mark_area[index].name, yAxis: mark_area[index].top }, // a name in one item is apprently enough ... + { yAxis: mark_area[index].bottom } ] ] - } + }; } - return options; - } + + }) + options.yAxis[0].axisLabel = {show: true}; + return options; } const getLineBarChartOptions = (legend, series, mark_area=null, x_axis='time', no_toolbox = false, graphic=null) => { @@ -404,6 +307,7 @@ const createChartContainer = (container, el) => {
`; + document.querySelector(container).appendChild(chart_node) chart_node.querySelector('.toggle-width').addEventListener("click", toggleWidth, false); diff --git a/frontend/js/helpers/config.js.example b/frontend/js/helpers/config.js.example index d08237aa1..42f01fad8 100644 --- a/frontend/js/helpers/config.js.example +++ b/frontend/js/helpers/config.js.example @@ -32,7 +32,43 @@ const radar_chart_condition = (metric) => { // filter function for the CO2 calculations in the Detailed Metrics // please note that this metric must be unique per phase -const co2_metrics_condition = (metric) => { +const phase_time_metric_condition = (metric) => { + if(metric == 'phase_time_syscall_system') return true; + return false; +} + +const machine_co2_metric_condition = (metric) => { if(metric.match(/^.*_co2_.*_machine$/) !== null) return true; return false; -} \ No newline at end of file +} + +const network_co2_metric_condition = (metric) => { + if(metric == 'network_co2_formula_global') return true; + return false; +} + +const network_energy_metric_condition = (metric) => { + if(metric == 'network_energy_formula_global') return true; + return false; +} + +const machine_power_metric_condition = (metric) => { + if(metric.match(/^.*_power_.*_machine$/) !== null) return true; + return false; +} + +const machine_energy_metric_condition = (metric) => { + if(metric.match(/^.*_energy_.*_machine$/) !== null) return true; + return false; +} + +const sci_metric_condition = (metric) => { + if(metric == 'software_carbon_intensity_global') return true; + return false; +} + +const embodied_carbon_share_metric_condition = (metric) => { + if(metric == 'embodied_carbon_share_machine') return true; + return false; +} + diff --git a/frontend/js/helpers/converters.js b/frontend/js/helpers/converters.js index 8751f42a8..dda32d67d 100644 --- a/frontend/js/helpers/converters.js +++ b/frontend/js/helpers/converters.js @@ -1,4 +1,11 @@ const convertValue = (value, unit) => { + // we do not allow a dynamic rescaling here, as we need all the units we feed into + // to be on the same order of magnitude + + if (value == null) return [value, unit]; + + if(unit.startsWith('ugCO2e/')) return [(value/(10**6)).toFixed(2), unit.substr(1)] + switch (unit) { case 'mJ': return [(value / 1_000).toFixed(2), 'J']; diff --git a/frontend/js/helpers/main.js b/frontend/js/helpers/main.js index 6100db7de..32df06e54 100644 --- a/frontend/js/helpers/main.js +++ b/frontend/js/helpers/main.js @@ -33,18 +33,30 @@ class GMTMenu extends HTMLElement { customElements.define('gmt-menu', GMTMenu); const replaceRepoIcon = (uri) => { - if (uri.startsWith("https://www.github.com") || uri.startsWith("https://github.com")) { - uri = uri.replace("https://www.github.com", ''); - uri = uri.replace("https://github.com", ''); - } else if (uri.startsWith("https://www.bitbucket.com") || uri.startsWith("https://bitbucket.com")) { - uri = uri.replace("https://www.bitbucket.com", ''); - uri = uri.replace("https://bitbucket.com", ''); - } else if (uri.startsWith("https://www.gitlab.com") || uri.startsWith("https://gitlab.com")) { - uri = uri.replace("https://www.gitlab.com", ''); - uri = uri.replace("https://gitlab.com", ''); - } - return uri; -} + + if(!uri.startsWith('http')) return uri; // ignore filesystem paths + + const url = new URL(uri); + + let iconClass = ""; + switch (url.host) { + case "github.com": + case "www.github.com": + iconClass = "github"; + break; + case "bitbucket.com": + case "www.bitbucket.com": + iconClass = "bitbucket"; + break; + case "gitlab.com": + case "www.gitlab.com": + iconClass = "gitlab"; + break; + default: + return uri; + } + return `` + uri.substring(url.origin.length); +}; const showNotification = (message_title, message_text, type='warning') => { $('body') @@ -82,6 +94,19 @@ const dateToYMD = (date, short=false) => { return ` ${date.getFullYear()}-${month}-${day}
${hours}:${minutes} UTC${offset}`; } +const escapeString = (string) =>{ + let my_string = String(string) + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + const reg = /[&<>"']/ig; + return my_string.replace(reg, (match) => map[match]); + } + async function makeAPICall(path, values=null) { if(values != null ) { diff --git a/frontend/js/helpers/metric-boxes.js b/frontend/js/helpers/metric-boxes.js index 669a27ace..bed8ba641 100644 --- a/frontend/js/helpers/metric-boxes.js +++ b/frontend/js/helpers/metric-boxes.js @@ -139,6 +139,25 @@ class PhaseMetrics extends HTMLElement { +
+
+
SCI
+
+
+
+ N/A +
+
+
+ via Formula + +
+
+ +
+
+
+

@@ -165,16 +184,27 @@ customElements.define('phase-metrics', PhaseMetrics); /* TODO: Include one sided T-test? */ -const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_data, comparison_key) => { +const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_name, detail_data) => { let max_value = '' if (detail_data.max != null) { let [max,max_unit] = convertValue(detail_data.max, metric_data.unit); - max_value = `${max} ${max_unit} (MAX)`; + max_value = `${max} ${max_unit}`; } let min_value = '' if (detail_data.min != null) { let [min,min_unit] = convertValue(detail_data.min, metric_data.unit); - min_value = `${min} ${min_unit} (MIN)`; + min_value = `${min} ${min_unit}`; + } + + let max_mean_value = '' + if (detail_data.max_mean != null) { + let [max_mean,max_unit] = convertValue(detail_data.max_mean, metric_data.unit); + max_mean_value = `${max_mean} ${max_unit}`; + } + let min_mean_value = '' + if (detail_data.min_mean != null) { + let [min_mean,min_unit] = convertValue(detail_data.min_mean, metric_data.unit); + min_mean_value = `${min_mean} ${min_unit}`; } @@ -192,19 +222,38 @@ const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_data, co 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(); - tr.innerHTML = ` -
- - - - - - - - `; + + if(detail_data.stddev != null) { + tr.innerHTML = ` + + + + + + + + + + + + `; + + } else { + tr.innerHTML = ` + + + + + + + + + `; + } + updateKeyMetric( - phase, metric_name, metric_data.clean_name, detail_data.name, + phase, metric_name, metric_data.clean_name, detail_name, value , std_dev_text, unit, metric_data.explanation, metric_data.source ); @@ -214,18 +263,26 @@ const displaySimpleMetricBox = (phase, metric_name, metric_data, detail_data, co This function assumes that detail_data has only two elements. For everything else we would need to calculate a trend / regression and not a simple comparison */ -const displayDiffMetricBox = (phase, metric_name, metric_data, detail_data_array, comparison_key, is_significant) => { - let extra_label = ''; - if (is_significant == true) extra_label = 'Significant'; - else extra_label = 'not significant / no-test'; +const displayDiffMetricBox = (phase, metric_name, metric_data, detail_name, detail_data_array, is_significant) => { // no max, we use significant rather + let extra_label = 'not significant / no-test'; + if (is_significant == true) extra_label = 'Significant'; - // no value conversion, cause we just use relatives - let value = detail_data_array[0].mean == 0 ? 0: (((detail_data_array[1].mean - detail_data_array[0].mean)/detail_data_array[0].mean)*100).toFixed(2); + // TODO: Remove this guard clause once we want to support more than 2 compared items + if (detail_data_array.length > 2) throw "Comparions > 2 currently not implemented" - let icon_color = 'positive'; + // no value conversion in this block, cause we just use relatives + let value = 'N/A'; + if (detail_data_array[0] == 0 && detail_data_array[1] == 0) { + value = 0; + } else if (detail_data_array[0] == null || detail_data_array[1] == null) { + value = 'not comparable'; + } else { + value = detail_data_array[0] == 0 ? 0: (((detail_data_array[1] - detail_data_array[0])/detail_data_array[0])*100).toFixed(2); + } + let icon_color = 'positive'; if (value > 0) { icon_color = 'error'; value = `+ ${value} %`; @@ -236,15 +293,16 @@ const displayDiffMetricBox = (phase, metric_name, metric_data, detail_data_array let scope = metric_name.split('_') scope = scope[scope.length-1] - let [value_1, unit] = convertValue(detail_data_array[0].mean, metric_data.unit); - let [value_2, _] = convertValue(detail_data_array[1].mean, metric_data.unit); + let [value_1, unit] = convertValue(detail_data_array[0], metric_data.unit); + let [value_2, _] = convertValue(detail_data_array[1], metric_data.unit); let tr = document.querySelector(`div.tab[data-tab='${phase}'] table.compare-metrics-table tbody`).insertRow(); tr.innerHTML = ` - + + @@ -252,7 +310,7 @@ const displayDiffMetricBox = (phase, metric_name, metric_data, detail_data_array `; updateKeyMetric( - phase, metric_name, metric_data.clean_name, detail_data_array[0].name, + phase, metric_name, metric_data.clean_name, detail_name, value, '', metric_data.unit, metric_data.explanation, metric_data.source ); @@ -301,17 +359,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(metric.match(/^.*_energy_.*_machine$/) !== null) { + if(machine_energy_metric_condition(metric)) { selector = '.machine-energy'; - } else if(metric == 'network_energy_formula_global') { + } else if(network_energy_metric_condition(metric)) { selector = '.network-energy'; - } else if(metric == 'phase_time_syscall_system') { + } else if(phase_time_metric_condition(metric)) { selector = '.phase-duration'; - } else if(metric == 'network_co2_formula_global') { + } else if(network_co2_metric_condition(metric)) { selector = '.network-co2'; - } else if(metric.match(/^.*_power_.*_machine$/) !== null) { + } else if(embodied_carbon_share_metric_condition(metric)) { + selector = '.embodied-carbon'; + } else if(sci_metric_condition(metric)) { + selector = '.software-carbon-intensity'; + } else if(machine_power_metric_condition(metric)) { selector = '.machine-power'; - } else if(metric.match(/^.*_co2_.*_machine$/) !== null) { + } else if(machine_co2_metric_condition(metric)) { selector = '.machine-co2'; } else { return; // could not match key metric @@ -321,11 +383,9 @@ const updateKeyMetric = (phase, metric_name, clean_name, detail_name, value, std document.querySelector(`div.tab[data-tab='${phase}'] ${selector} .value span`).innerText = `${(value)} ${std_dev_text}` document.querySelector(`div.tab[data-tab='${phase}'] ${selector} .si-unit`).innerText = `[${unit}]` if(std_dev_text != '') document.querySelector(`div.tab[data-tab='${phase}'] ${selector} .metric-type`).innerText = `(AVG + STD.DEV)`; - else if(value.indexOf('%') !== -1) document.querySelector(`div.tab[data-tab='${phase}'] ${selector} .metric-type`).innerText = `(Diff. in %)`; + else if(String(value).indexOf('%') !== -1) document.querySelector(`div.tab[data-tab='${phase}'] ${selector} .metric-type`).innerText = `(Diff. in %)`; node = document.querySelector(`div.tab[data-tab='${phase}'] ${selector} .source`) if (node !== null) node.innerText = source // not every key metric shall have a custom detail_name } - - diff --git a/frontend/js/helpers/phase-stats.js b/frontend/js/helpers/phase-stats.js index 99759d2f4..e1bcfee00 100644 --- a/frontend/js/helpers/phase-stats.js +++ b/frontend/js/helpers/phase-stats.js @@ -1,33 +1,43 @@ -const setupPhaseTabs = (phase_stats_object, multi_comparison) => { - let keys = Object.keys(phase_stats_object['data']) - // only need to traverse one branch in case of a comparison - // no need to display phases that do not exist in both - for (phase in phase_stats_object['data'][keys[0]]) { - createPhaseTab(phase); - let tr = document.querySelector(`div.tab[data-tab='${phase}'] .compare-metrics-table thead`).insertRow(); - if (multi_comparison) { - tr.innerHTML = ` - - - - - - - - - `; - } else { - tr.innerHTML = ` - - - - - - - - - `; - } +const createTableHeader = (phase, comparison_keys, comparison_case, comparison_amounts) => { + let tr = document.querySelector(`div.tab[data-tab='${phase}'] .compare-metrics-table thead`).insertRow(); + + if (comparison_amounts >= 2) { + tr.innerHTML = ` + + + + + + + + + + `; + } else if(comparison_case !== null) { + tr.innerHTML = ` + + + + + + + + + + + + `; + } else { + tr.innerHTML = ` + + + + + + + + + `; } } @@ -37,29 +47,8 @@ const showWarning = (phase, warning) => { const newListItem = document.createElement("li"); newListItem.textContent = warning; document.querySelector(`div.tab[data-tab='${phase}'] .ui.warning.message ul`).appendChild(newListItem); - - } -const determineMultiComparison = (comparison_case) => { - switch (comparison_case) { - case null: // single value - case 'Repeated Run': - return false; - break; - case 'Branch': - case 'Usage Scenario': - case 'Commit': - case 'Machine': - case 'Repository': - return true; - break; - default: - throw `Unknown comparison case: ${comparison_case}` - } -} - - /* This function was originally written to include new phases in the "steps" @@ -78,21 +67,21 @@ const determineMultiComparison = (comparison_case) => { not prepend */ const createPhaseTab = (phase) => { - let phase_tab_node = document.querySelector(`a.step[data-tab='${phase}']`); - if(phase_tab_node == null || phase_tab_node == undefined) { + + if(phase_tab_node == null) { let runtime_tab_node = document.querySelector('a.runtime-step'); let cloned_tab_node = runtime_tab_node.cloneNode(true); cloned_tab_node.style.display = ''; cloned_tab_node.innerText = phase; cloned_tab_node.setAttribute('data-tab', phase); - runtime_tab_node.parentNode.insertBefore(cloned_tab_node, runtime_tab_node) + runtime_tab_node.parentNode.appendChild(cloned_tab_node) let phase_step_node = document.querySelector('.runtime-tab'); let cloned_step_node = phase_step_node.cloneNode(true); cloned_step_node.style.display = ''; cloned_step_node.setAttribute('data-tab', phase); - phase_step_node.parentNode.insertBefore(cloned_step_node, phase_step_node) + phase_step_node.parentNode.appendChild(cloned_step_node) } } @@ -100,27 +89,35 @@ const createPhaseTab = (phase) => { We traverse the multi-dimensional metrics object only once and fill data in the appropriate variables for metric-boxes and charts */ -const displayComparisonMetrics = (phase_stats_object, comparison_case, multi_comparison) => { - - let keys = Object.keys(phase_stats_object['data']) +const displayComparisonMetrics = (phase_stats_object) => { + // we now traverse all branches of the tree + // the tree is sparese, so although we see all metrics that are throughout all phases and comparison_keys + // not every branch has a leaf with the metric for the corresponding comparsion_key ... it might be missing + // we must fill this value than with a NaN in the charts - // we need to traverse only one branch of the tree and copy in all other values if - // a matching metric exists in the other branch - // we first go through one branch until we reach the detail object - // we identify if a metric is a key metric regarding something and handle that - // we identify if a metric is a normal metric regarding something and handle that // if we have a comparison case between the two we just display the difference between them // if we have a repetition case we display the STDDEV // otherwise we just display the value + // unsure atm what to do in a Diff scenario ... filling with 0 can be misleading + let total_chart_bottom_data = {}; let total_chart_bottom_legend = {}; let total_chart_bottom_labels = []; - for (phase in phase_stats_object['data'][keys[0]]) { - let phase_data = phase_stats_object['data'][keys[0]][phase]; + + for (phase in phase_stats_object['data']) { + createPhaseTab(phase); // will not create already existing phase tabs + createTableHeader( + phase, + phase_stats_object.comparison_details, + phase_stats_object.comparison_case, + phase_stats_object.comparison_details.length + ) + + let phase_data = phase_stats_object['data'][phase]; let radar_chart_labels = []; - let radar_chart_data = [[],[]]; + let radar_chart_data = [...Array(phase_stats_object.comparison_details.length)].map(e => Array(0)); let top_bar_chart_labels = []; let top_bar_chart_data = [[],[]]; @@ -128,142 +125,130 @@ const displayComparisonMetrics = (phase_stats_object, comparison_case, multi_com total_chart_bottom_legend[phase] = []; let co2_calculated = false; - let found_bottom_chart_metric = false; - let phase_key0_has_machine_energy = false; - let phase_key1_has_machine_energy = false; + // the following variables are needed for filling missing values in charts and the machine energy + // we need to keep track if in the coming loop a matching metric was found or mitigate the missing value + 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] + let found_radar_chart_item = false; + for (detail in metric_data['data']) { let detail_data = metric_data['data'][detail] - // push data to chart that we need in any case - if(radar_chart_condition(metric) && multi_comparison) { + /* + 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); - radar_chart_data[0].push(detail_data.mean) } if (top_bar_chart_condition(metric)) { top_bar_chart_labels.push(`${metric_data.clean_name} (${metric_data.source})`); - top_bar_chart_data[0].push(detail_data.mean) } + if (total_chart_bottom_condition(metric)) { 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.`); } else { total_chart_bottom_legend[phase].push(metric_data.clean_name); - - if(total_chart_bottom_data?.[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[0]}`] == null) { - total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[0]}`] = [] - } - total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[0]}`].push(detail_data.mean) - phase_key0_has_machine_energy = true - found_bottom_chart_metric = metric; + found_bottom_chart_metric = `${metric} ${detail_data['name']}`; } } + /* END BLOCK LABELS*/ - if (comparison_case == null && co2_metrics_condition(metric)) { - if(co2_calculated) { - showWarning(phase, 'CO2 was already calculated! Do you have CO2 Machine reporters set'); - } - co2_calculated = true; - calculateCO2(phase, detail_data.mean); + if (Object.keys(detail_data['data']).length != phase_stats_object.comparison_details.length) { + showWarning(phase, `${metric} ${detail} was missing from at least one comparison.`); } - if (!multi_comparison) { - displaySimpleMetricBox(phase,metric, metric_data, detail_data, keys[0]); - if(comparison_case !== null) { - displayCompareChart( - phase, - `${metric_data.clean_name} (${metric_data.source} ${detail}) - [${metric_data.unit}]`, - [`${comparison_case}: ${keys[0]}`], - [detail_data.values], - [{name:'Confidence Interval', bottom: detail_data.mean-detail_data.ci, top: detail_data.mean+detail_data.ci}], - ); + let compare_chart_data = [] + let compare_chart_mark = [] + let compare_chart_labels = [] + let metric_box_data = [...Array(phase_stats_object.comparison_details.length)].map(e => {{}}) + + // 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) { + radar_chart_data[key_index].push(detail_data['data'][key]?.mean) } - } else { - let metric_data2 = phase_stats_object?.['data']?.[keys[1]]?.[phase]?.[metric] - let detail_data2 = metric_data2?.['data']?.[detail] - if (detail_data2 == undefined) { - // the metric or phase might not be present in the other run - // note that this debug statement does not log when on the second branch more metrics are - // present that are not shown. However we also do not want to display them. - showWarning(phase, `${metric} ${detail} was missing from one comparison. Skipping`); - continue; + if (top_bar_chart_condition(metric)) { + 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_data?.[`${TOTAL_CHART_BOTTOM_LABEL} - ${key}`] == null) { + total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${key}`] = [] + } + total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${key}`].push(detail_data['data'][key]?.mean) + bottom_chart_present_keys[key] = true } + + if (phase_stats_object.comparison_case == null && machine_co2_metric_condition(metric)) { + if(co2_calculated) { + showWarning(phase, 'CO2 was already calculated! Do you have multiple machine energy reporters set?'); + } + // mean will always be present, as we only have one key and thus we need no ?. + calculateCO2(phase, detail_data['data'][key].mean); + co2_calculated = true; + } + + metric_box_data[key_index] = detail_data['data'][key]?.mean + compare_chart_data.push(detail_data['data'][key]?.values) + compare_chart_labels.push(`${phase_stats_object.comparison_case}: ${key}`) + compare_chart_mark.push({ + name:'Confidence Interval', + bottom: detail_data['data'][key]?.mean-detail_data['data'][key]?.ci, + top: detail_data['data'][key]?.mean+detail_data['data'][key]?.ci + }) + }) // end key + + 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]]); + } else { displayDiffMetricBox( - phase, metric, metric_data, [detail_data, detail_data2], - keys[0], phase_stats_object.statistics?.[phase]?.[metric]?.[detail]?.is_significant + phase, metric, metric_data, detail_data['name'], metric_box_data, + detail_data.is_significant ); - - detail_chart_data = [detail_data.values,detail_data2.values] - detail_chart_mark = [ - {name:'Confidence Interval', bottom: detail_data.mean-detail_data.ci, top: detail_data.mean+detail_data.ci}, - {name:'Confidence Interval', bottom: detail_data2.mean-detail_data2.ci, top: detail_data2.mean+detail_data2.ci}, - ] + } + if(phase_stats_object.comparison_case !== null) { // compare charts will display for everything apart stats.html displayCompareChart( phase, `${metric_data.clean_name} (${detail}) - [${metric_data.unit}]`, - [`${comparison_case}: ${keys[0]}`, `${comparison_case}: ${keys[1]}`], - detail_chart_data, - detail_chart_mark, + compare_chart_labels, + compare_chart_data, + compare_chart_mark, ); - - if(radar_chart_condition(metric) && multi_comparison) { - radar_chart_data[1].push(detail_data2.mean) - } - - if (top_bar_chart_condition(metric)) { - top_bar_chart_data[1].push(detail_data2.mean) - } - if (total_chart_bottom_condition(metric)) { - if(found_bottom_chart_metric && found_bottom_chart_metric !== 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.`); - } else { - if(total_chart_bottom_data?.[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[1]}`] == null) { - total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[1]}`] = [] - } - - total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[1]}`].push(detail_data2.mean) - phase_key1_has_machine_energy = true - } - } } + } // end detail + } // end metric + + // a phase had no bottom chart metric and must be null-filled + // this can for instance happen if a phase is too short and no metric was reported in the timespan + for (key in bottom_chart_present_keys) { + if(bottom_chart_present_keys[key] == false) { + if(total_chart_bottom_data?.[`${TOTAL_CHART_BOTTOM_LABEL} - ${key}`] == null) { + total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${key}`] = [] + } + total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${key}`].push(NaN) } } - // phase ended. Render out the chart - - - let radar_legend = [] - if (multi_comparison) { - radar_legend = [`${comparison_case}: ${keys[0]}`, `${comparison_case}: ${keys[1]}`] - } else { - radar_legend = [keys[0]] - } - if (phase_key0_has_machine_energy == false) { // add dummy - if(total_chart_bottom_data?.[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[0]}`] == null) { - total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[0]}`] = [] - } - total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[0]}`].push(0) - } - if (phase_key1_has_machine_energy == false && multi_comparison == 2) { // add dummy - if(total_chart_bottom_data?.[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[1]}`] == null) { - total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[1]}`] = [] - } - total_chart_bottom_data[`${TOTAL_CHART_BOTTOM_LABEL} - ${keys[1]}`].push(0) - } + let radar_legend = phase_stats_object.comparison_details.map(e => `${phase_stats_object.comparison_case}: ${e}`) - if(multi_comparison) { + if(phase_stats_object.comparison_details.length >= 2) { displayKeyMetricsRadarChart( radar_legend, radar_chart_labels, radar_chart_data, phase ); - } else if(comparison_case != null) { // stats.html does not even have it. so only remove for Repeated Run etc. + } else if(phase_stats_object.comparison_case != null) { + // stats.html does not even have it. so only remove for Repeated Run etc. removeKeyMetricsRadarChart(phase) } @@ -276,7 +261,8 @@ const displayComparisonMetrics = (phase_stats_object, comparison_case, multi_com // displayKeyMetricsEmbodiedCarbonChart(phase); - } + } // phase end + displayTotalChart( total_chart_bottom_legend, total_chart_bottom_labels, @@ -313,4 +299,3 @@ const displayComparisonMetrics = (phase_stats_object, comparison_case, multi_com window.dispatchEvent(new Event('resize')); } - diff --git a/frontend/js/index.js b/frontend/js/index.js index 1ddaf0a8e..002bb9c72 100644 --- a/frontend/js/index.js +++ b/frontend/js/index.js @@ -5,7 +5,7 @@ const compareButton = () => { checkedBoxes.forEach(checkbox => { link = `${link}${checkbox.value},`; }); - window.location = link.substr(0,link.length-1); + window.open(link.substr(0,link.length-1), '_blank'); } const updateCompareCount = () => { const countButton = document.getElementById('compare-button'); @@ -13,7 +13,23 @@ const updateCompareCount = () => { countButton.textContent = `Compare: ${checkedCount} Run(s)`; } -function allow_group_select_checkboxes(checkbox_wrapper_id){ +const removeFilter = (paramName) => { + const urlSearchParams = new URLSearchParams(window.location.search); + urlSearchParams.delete(paramName); + const newUrl = `${window.location.pathname}?${urlSearchParams.toString()}`; + window.location.href = newUrl; +} + +const showActiveFilters = (key, value) => { + document.querySelector(`.ui.warning.message`).classList.remove('hidden'); + const newListItem = document.createElement("span"); + newListItem.innerHTML = `
${escapeString(key)}: ${escapeString(value)}
`; + document.querySelector(`.ui.warning.message ul`).appendChild(newListItem); + +} + + +const allow_group_select_checkboxes = (checkbox_wrapper_id) => { let lastChecked = null; let checkboxes = document.querySelectorAll(checkbox_wrapper_id); @@ -42,10 +58,21 @@ function allow_group_select_checkboxes(checkbox_wrapper_id){ (async () => { try { - var api_data = await makeAPICall('/v1/projects') + const url_params = (new URLSearchParams(window.location.search)) + let repo_filter = ''; + if (url_params.get('repo') != null && url_params.get('repo').trim() != '') { + repo_filter = url_params.get('repo').trim() + showActiveFilters('repo', repo_filter) + } + let filename_filter = ''; + if (url_params.get('filename') != null && url_params.get('filename').trim() != '') { + filename_filter = url_params.get('filename').trim() + showActiveFilters('filename', filename_filter) + } + var api_data = await makeAPICall(`/v1/projects?repo=${repo_filter}&filename=${filename_filter}`) } catch (err) { - showNotification('Could not get data from API', err); - return; + showNotification('Could not get data from API', err); + return; } api_data.data.forEach(el => { diff --git a/frontend/js/request.js b/frontend/js/request.js index 02e6d0cbb..1ed7058c8 100644 --- a/frontend/js/request.js +++ b/frontend/js/request.js @@ -3,6 +3,7 @@ var machines_json = await makeAPICall('/v1/machines/'); machines_json.data.forEach(machine => { + if(machine[2] == false) return; let newOption = new Option(machine[1],machine[0]); const select = document.querySelector('select'); select.add(newOption,undefined); diff --git a/frontend/js/stats.js b/frontend/js/stats.js index fffe250b8..3dd8ad5d7 100644 --- a/frontend/js/stats.js +++ b/frontend/js/stats.js @@ -355,8 +355,7 @@ $(document).ready( (e) => { fillProjectData(project_data); if(phase_stats_data != null) { - setupPhaseTabs(phase_stats_data, false) - displayComparisonMetrics(phase_stats_data, phase_stats_data.comparison_case, false) + displayComparisonMetrics(phase_stats_data) } if (measurements_data == undefined) return; diff --git a/frontend/stats.html b/frontend/stats.html index ef4332577..c83829740 100644 --- a/frontend/stats.html +++ b/frontend/stats.html @@ -71,7 +71,7 @@

Project Data

- XGBoost estimated AC energy + XGBoost estimated AC energy (Runtime)
@@ -79,7 +79,7 @@

Project Data

- RAPL component energy + RAPL component energy (Runtime)
@@ -87,7 +87,15 @@

Project Data

- Measured AC energy + Measured AC energy (Runtime) +
+ +
+
+
+ +
+ SCI (Runtime)
diff --git a/install_linux.sh b/install_linux.sh index fd6b08495..6e68cb581 100755 --- a/install_linux.sh +++ b/install_linux.sh @@ -98,11 +98,13 @@ sed -i -e "s|__METRICS_URL__|$metrics_url|" frontend/js/helpers/config.js print_message "Checking out further git submodules ..." git submodule update --init +sudo apt-get update + print_message "Installing needed binaries for building ..." if lsb_release -is | grep -q "Fedora"; then sudo dnf -y install lm_sensors lm_sensors-devel glib2 glib2-devel else - sudo apt install -y lm-sensors libsensors-dev libglib2.0-0 libglib2.0-dev + sudo apt-get install -y lm-sensors libsensors-dev libglib2.0-0 libglib2.0-dev fi print_message "Building binaries ..." @@ -130,7 +132,7 @@ PWD=$(pwd) echo "ALL ALL=(ALL) NOPASSWD:$PYTHON_PATH $PWD/lib/hardware_info_root.py" | sudo tee /etc/sudoers.d/green_coding_hardware_info print_message "Installing IPMI tools" -sudo apt-get install freeipmi-tools ipmitool +sudo apt-get install -y freeipmi-tools ipmitool print_message "Adding IPMI to sudoers file" echo "ALL ALL=(ALL) NOPASSWD:/usr/sbin/ipmi-dcmi --get-system-power-statistics" | sudo tee /etc/sudoers.d/ipmi_get_machine_energy_stat @@ -163,6 +165,9 @@ if [[ $no_build != true ]] ; then print_message "Building / Updating docker containers" docker compose -f docker/compose.yml down docker compose -f docker/compose.yml build + + print_message "Updating python requirements" + python3 -m pip install -r requirements.txt fi echo "" diff --git a/install_mac.sh b/install_mac.sh index 39c0f0e08..f9f547d6e 100755 --- a/install_mac.sh +++ b/install_mac.sh @@ -35,7 +35,7 @@ fi if [[ -z $metrics_url ]] ; then read -p "Please enter the desired metrics dashboard URL: (default: http://metrics.green-coding.internal:9142): " metrics_url metrics_url=${metrics_url:-"http://metrics.green-coding.internal:9142"} -fi +fi if [[ -z "$db_pw" ]] ; then read -sp "Please enter the new password to be set for the PostgreSQL DB: " db_pw @@ -75,6 +75,7 @@ git submodule update --init print_message "Adding hardware_info_root.py to sudoers file" echo "ALL ALL=(ALL) NOPASSWD:/usr/bin/powermetrics" | sudo tee /etc/sudoers.d/green_coding_powermetrics echo "ALL ALL=(ALL) NOPASSWD:/usr/bin/killall powermetrics" | sudo tee /etc/sudoers.d/green_coding_kill_powermetrics +echo "ALL ALL=(ALL) NOPASSWD:/usr/bin/killall -9 powermetrics" | sudo tee /etc/sudoers.d/green_coding_kill_powermetrics_sigkill etc_hosts_line_1="127.0.0.1 green-coding-postgres-container" @@ -102,5 +103,8 @@ print_message "Building / Updating docker containers" docker compose -f docker/compose.yml down docker compose -f docker/compose.yml build +print_message "Updating python requirements" +python3 -m pip install -r requirements.txt + echo "" echo -e "${GREEN}Successfully installed Green Metrics Tool!${NC}" diff --git a/lib/db.py b/lib/db.py index fc0606aed..d9075821f 100644 --- a/lib/db.py +++ b/lib/db.py @@ -21,7 +21,6 @@ def __init__(self): # pylint: disable=consider-using-f-string if config['postgresql']['host'] is None: self._conn = psycopg.connect("user=%s dbname=%s password=%s port=%s" % ( - config['postgresql']['port'], config['postgresql']['user'], config['postgresql']['dbname'], config['postgresql']['password'], diff --git a/lib/hardware_info.py b/lib/hardware_info.py index 1fc6a4302..2d99596a9 100755 --- a/lib/hardware_info.py +++ b/lib/hardware_info.py @@ -92,6 +92,7 @@ def read_directory_recursive(directory): [rfwr, 'Virtualization', '/proc/cpuinfo', r'(?Phypervisor)'], [rpwrs, 'SGX', f"{os.path.join(CURRENT_PATH, '../tools/sgx_enable')} -s", r'(?P.*)', re.IGNORECASE | re.DOTALL], [rfwr, 'IO scheduling', '/sys/block/sda/queue/scheduler', r'(?P.*)'], + [rpwr, 'Network Interfaces', 'ip addr | grep ether -B 1', r'(?P.*)', re.IGNORECASE | re.DOTALL], ] # This is a very slimmed down version in comparison to the linux list. This is because we will not be using this @@ -106,6 +107,8 @@ def read_directory_recursive(directory): [rpwr, 'Docker Info', 'docker info', r'(?P.*)', re.IGNORECASE | re.DOTALL], [rpwr, 'Docker Version', 'docker version', r'(?P.*)', re.IGNORECASE | re.DOTALL], [rpwr, 'Processes', '/bin/ps -ax', r'(?P.*)', re.IGNORECASE | re.DOTALL], + [rpwr, 'Network Interfaces', 'ifconfig | grep -E "flags|ether"', r'(?P.*)', re.IGNORECASE | re.DOTALL], + ] def get_list(): diff --git a/metric_providers/base.py b/metric_providers/base.py index 6eda72ac1..2999e3265 100644 --- a/metric_providers/base.py +++ b/metric_providers/base.py @@ -72,7 +72,7 @@ def read_metrics(self, project_id, containers): elif self._metrics.get('container_id') is not None: df['detail_name'] = df.container_id for container_id in containers: - df.loc[df.detail_name == container_id, 'detail_name'] = containers[container_id] + df.loc[df.detail_name == container_id, 'detail_name'] = containers[container_id]['name'] df = df.drop('container_id', axis=1) else: # We use the default granularity from the name of the provider eg. "..._machine" => [MACHINE] df['detail_name'] = f"[{self._metric_name.split('_')[-1]}]" @@ -133,6 +133,7 @@ def stop_profiling(self): except subprocess.TimeoutExpired: # If the process hasn't gracefully exited after 5 seconds we kill it os.killpg(ps_group_id, signal.SIGKILL) + print("Killed the process with SIGKILL. This could lead to corrupted metric log files!") except ProcessLookupError: print(f"Could not find process-group for {self._ps.pid}", diff --git a/metric_providers/powermetrics/provider.py b/metric_providers/powermetrics/provider.py index c15dee513..569987ec3 100644 --- a/metric_providers/powermetrics/provider.py +++ b/metric_providers/powermetrics/provider.py @@ -2,6 +2,8 @@ import subprocess import plistlib from datetime import timezone +import time +import xml import pandas #pylint: disable=import-error @@ -33,13 +35,37 @@ def __init__(self, resolution): '-o', self._filename] + def is_powermetrics_running(self): + try: + output = subprocess.check_output('pgrep -x powermetrics', shell=True) + return bool(output.strip()) # If the output is not empty, the process is running. + + except subprocess.CalledProcessError: # If the process is not running, 'pgrep' returns non-zero exit code. + return False + + def stop_profiling(self): try: # We try calling the parent method but if this doesn't work we use the more hardcore approach super().stop_profiling() except PermissionError: - # This isn't the nicest way of doing this but there isn't really any other way that is nicer + #This isn't the nicest way of doing this but there isn't really any other way that is nicer subprocess.check_output('sudo /usr/bin/killall powermetrics', shell=True) + print('Killed powermetrics process with killall!') + + # As killall returns right after sending the SIGKILL we need to wait and make sure that the process + # had time to flush everything to disk + count = 0 + while self.is_powermetrics_running(): + print(f"Waiting for powermetrics to shut down (try {count}/60). Please do not abort ...") + time.sleep(1) + count += 1 + if count >= 60: + subprocess.check_output('sudo /usr/bin/killall -9 powermetrics', shell=True) + raise RuntimeError('powermetrics had to be killed with kill -9. Values can not be trusted!') + + # We need to give the OS a second to flush + time.sleep(1) self._ps = None @@ -56,8 +82,15 @@ def read_metrics(self, project_id, containers=None): dfs = [] cum_time = None - for data in datas: - data = plistlib.loads(data) + for count, data in enumerate(datas, start=1): + try: + data = plistlib.loads(data) + except xml.parsers.expat.ExpatError as e: + print('There was an error parsing the powermetrics data!') + print(f"Iteration count: {count}") + print(f"Number of items in datas: {len(datas)}") + print(data) + raise e if cum_time is None: # Convert seconds to nano seconds diff --git a/metric_providers/psu/energy/ac/sdia/machine/provider.py b/metric_providers/psu/energy/ac/sdia/machine/provider.py index 446c8eaf3..02643a06f 100644 --- a/metric_providers/psu/energy/ac/sdia/machine/provider.py +++ b/metric_providers/psu/energy/ac/sdia/machine/provider.py @@ -29,7 +29,6 @@ def get_stderr(self): # All work is done by reading system cpu utilization file def start_profiling(self, containers=None): self._has_started = True - return # noop def read_metrics(self, project_id, containers): diff --git a/metric_providers/psu/energy/ac/xgboost/machine/provider.py b/metric_providers/psu/energy/ac/xgboost/machine/provider.py index 309b2ec8a..37297a160 100644 --- a/metric_providers/psu/energy/ac/xgboost/machine/provider.py +++ b/metric_providers/psu/energy/ac/xgboost/machine/provider.py @@ -7,7 +7,7 @@ sys.path.append(f"{CURRENT_DIR}/../../../../../../lib") sys.path.append(CURRENT_DIR) -#pylint: disable=import-error +#pylint: disable=import-error, wrong-import-position import model.xgb as mlmodel from global_config import GlobalConfig from metric_providers.base import BaseMetricProvider @@ -29,7 +29,6 @@ def get_stderr(self): # All work is done by reading system cpu utilization file def start_profiling(self, containers=None): self._has_started = True - return # noop def read_metrics(self, project_id, containers): diff --git a/requirements-dev.txt b/requirements-dev.txt index fdbeb438c..a40a61424 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -pydantic==2.0.3 +pydantic==2.1.1 pytest==7.4.0 requests==2.31.0 -pylint==2.17.4 +pylint==2.17.5 diff --git a/requirements.txt b/requirements.txt index a92a8d785..688caa320 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ PyYAML==6.0.1 pandas==2.0.3 -psycopg[binary]==3.1.9 +psycopg[binary]==3.1.10 pyserial==3.5 schema==0.7.5 \ No newline at end of file diff --git a/runner.py b/runner.py index e040d0cb1..d27a048db 100755 --- a/runner.py +++ b/runner.py @@ -59,6 +59,9 @@ def arrows(text): def join_paths(path, path2, mode=None): filename = os.path.realpath(os.path.join(path, path2)) + # If the original path is a symlink we need to resolve it. + path = os.path.realpath(path) + # This is a special case in which the file is '.' if filename == path.rstrip('/'): return filename @@ -94,7 +97,7 @@ def __init__(self, uri, uri_type, pid, filename='usage_scenario.yml', branch=None, debug_mode=False, allow_unsafe=False, no_file_cleanup=False, skip_config_check=False, skip_unsafe=False, verbose_provider_boot=False, full_docker_prune=False, - dry_run=False, dev_repeat_run=False): + dry_run=False, dev_repeat_run=False, docker_prune=False): if skip_unsafe is True and allow_unsafe is True: raise RuntimeError('Cannot specify both --skip-unsafe and --allow-unsafe') @@ -107,6 +110,7 @@ def __init__(self, self._skip_config_check = skip_config_check self._verbose_provider_boot = verbose_provider_boot self._full_docker_prune = full_docker_prune + self._docker_prune = docker_prune self._dry_run = dry_run self._dev_repeat_run = dev_repeat_run self._uri = uri @@ -117,6 +121,8 @@ def __init__(self, self._tmp_folder = '/tmp/green-metrics-tool' self._usage_scenario = {} self._architecture = utils.get_architecture() + self._sci = {'R_d': None, 'R': 0} + # transient variables that are created by the runner itself # these are accessed and processed on cleanup and then reset @@ -153,6 +159,8 @@ def save_notes_runner(self): self.__notes_helper.save_to_db(self._project_id) def check_configuration(self): + print(TerminalColors.HEADER, '\nStarting configuration check', TerminalColors.ENDC) + if self._skip_config_check: print("Configuration check skipped") return @@ -179,6 +187,7 @@ def check_configuration(self): ) def checkout_repository(self): + print(TerminalColors.HEADER, '\nChecking out repository', TerminalColors.ENDC) if self._uri_type == 'URL': # always remove the folder if URL provided, cause -v directory binding always creates it @@ -245,8 +254,6 @@ def checkout_repository(self): 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 @@ -338,6 +345,15 @@ def merge_dicts(dict1, dict2): del yml_obj['compose-file'] yml_obj.update(new_dict) + + # If a service is defined as None we remove it. This is so we can have a compose file that starts + # all the various services but we can disable them in the usage_scenario. This is quite useful when + # creating benchmarking scripts and you want to have all options in the compose but not in each benchmark. + # The cleaner way would be to handle an empty service key throughout the code but would make it quite messy + # so we chose to remove it right at the start. + for key in [sname for sname, content in yml_obj['services'].items() if content is None]: + del yml_obj['services'][key] + self._usage_scenario = yml_obj def initial_parse(self): @@ -357,6 +373,8 @@ def initial_parse(self): if self._usage_scenario.get('architecture') is not None and self._architecture != self._usage_scenario['architecture'].lower(): raise RuntimeError(f"Specified architecture does not match system architecture: system ({self._architecture}) != specified ({self._usage_scenario.get('architecture')})") + self._sci['R_d'] = self._usage_scenario.get('sci', {}).get('R_d', None) + def check_running_containers(self): result = subprocess.run(['docker', 'ps' ,'--format', '{{.Names}}'], stdout=subprocess.PIPE, @@ -385,18 +403,24 @@ def remove_docker_images(self): if self._dev_repeat_run: return + print(TerminalColors.HEADER, '\nRemoving all temporary GMT images', TerminalColors.ENDC) subprocess.run( 'docker images --format "{{.Repository}}:{{.Tag}}" | grep "gmt_run_tmp" | xargs docker rmi -f', shell=True, stderr=subprocess.DEVNULL, # to suppress showing of stderr check=False, ) + if self._full_docker_prune: + print(TerminalColors.HEADER, '\nStopping and removing all containers, build caches, volumes and images on the system', TerminalColors.ENDC) subprocess.run('docker ps -aq | xargs docker stop', shell=True, check=False) subprocess.run('docker images --format "{{.ID}}" | xargs docker rmi -f', shell=True, check=False) subprocess.run(['docker', 'system', 'prune' ,'--force', '--volumes'], check=True) + elif self._docker_prune: + print(TerminalColors.HEADER, '\nRemoving all unassociated build caches, networks volumes and stopped containers on the system', TerminalColors.ENDC) + subprocess.run(['docker', 'system', 'prune' ,'--force', '--volumes'], check=True) else: - print(TerminalColors.WARNING, arrows('Warning: GMT is not instructed to prune docker images and build caches. This is most likely what you want for development, but leads to wrong build time measurements in production.'), TerminalColors.ENDC) + print(TerminalColors.WARNING, arrows('Warning: GMT is not instructed to prune docker images and build caches. \nWe recommend to set --docker-prune to remove build caches and anonymous volumes, because otherwise your disk will get full very quickly. If you want to measure also network I/O delay for pulling images and have a dedicated measurement machine please set --full-docker-prune'), TerminalColors.ENDC) ''' A machine will always register in the database on run. @@ -450,6 +474,9 @@ def update_and_insert_specs(self): machine_specs.update(machine_specs_root) + keys = ["measurement", "sci"] + measurement_config = {key: config.get(key, None) for key in keys} + # Insert auxilary info for the run. Not critical. DB().query(""" UPDATE projects @@ -460,7 +487,7 @@ def update_and_insert_specs(self): """, params=( config['machine']['id'], escape(json.dumps(machine_specs), quote=False), - json.dumps(config['measurement']), + json.dumps(measurement_config), escape(json.dumps(self._usage_scenario), quote=False), self._original_filename, gmt_hash, @@ -770,13 +797,19 @@ def setup_services(self): ps = subprocess.run( docker_run_string, check=True, - stderr=subprocess.PIPE, stdout=subprocess.PIPE, + #stderr=subprocess.DEVNULL, // not setting will show in CLI encoding='UTF-8' ) container_id = ps.stdout.strip() - self.__containers[container_id] = container_name + self.__containers[container_id] = { + 'name': container_name, + 'log-stdout': service.get('log-stdout', False), + 'log-stderr': service.get('log-stderr', True), + 'read-sci-stdout': service.get('read-sci-stdout', False), + } + print('Stdout:', container_id) if 'setup-commands' not in service: @@ -860,15 +893,15 @@ def start_metric_providers(self, allow_container=True, allow_other=True): raise RuntimeError(f"Stderr on {metric_provider.__class__.__name__} was NOT empty: {stderr_read}") - def start_phase(self, phase): + def start_phase(self, phase, transition = True): config = GlobalConfig().config - print(TerminalColors.HEADER, f"\nStarting phase {phase}. Force-sleeping for {config['measurement']['phase-transition-time']}s", TerminalColors.ENDC) - - self.custom_sleep(config['measurement']['phase-transition-time']) - - print(TerminalColors.HEADER, '\nForce-sleep endeded. Checking if temperature is back to baseline ...', TerminalColors.ENDC) + print(TerminalColors.HEADER, f"\nStarting phase {phase}.", TerminalColors.ENDC) - # TODO. Check if temperature is back to baseline and put into best-practices section + if transition: + # The force-sleep must go and we must actually check for the temperature baseline + print(f"\nForce-sleeping for {config['measurement']['phase-transition-time']}s") + self.custom_sleep(config['measurement']['phase-transition-time']) + print(TerminalColors.HEADER, '\nChecking if temperature is back to baseline ...', TerminalColors.ENDC) phase_time = int(time.time_ns() / 1_000) self.__notes_helper.add_note({'note': f"Starting phase {phase}", 'detail_name': '[NOTES]', 'timestamp': phase_time}) @@ -902,7 +935,7 @@ def run_flows(self): for el in self._usage_scenario['flow']: print(TerminalColors.HEADER, '\nRunning flow: ', el['name'], TerminalColors.ENDC) - self.start_phase(el['name'].replace('[', '').replace(']','')) + self.start_phase(el['name'].replace('[', '').replace(']',''), transition=False) for inner_el in el['commands']: if 'note' in inner_el: @@ -930,7 +963,7 @@ def run_flows(self): stderr_behaviour = stdout_behaviour = subprocess.DEVNULL if inner_el.get('log-stdout', False): stdout_behaviour = subprocess.PIPE - if inner_el.get('log-stderr', False): + if inner_el.get('log-stderr', True): stderr_behaviour = subprocess.PIPE @@ -966,6 +999,7 @@ def run_flows(self): 'container_name': el['container'], 'read-notes-stdout': inner_el.get('read-notes-stdout', False), 'ignore-errors': inner_el.get('ignore-errors', False), + 'read-sci-stdout': inner_el.get('read-sci-stdout', False), 'detail_name': el['container'], 'detach': inner_el.get('detach', False), }) @@ -1017,14 +1051,17 @@ def read_and_cleanup_processes(self): stderr = ps['ps'].stderr if stdout: - stdout = stdout.splitlines() - for line in stdout: + for line in stdout.splitlines(): print('stdout from process:', ps['cmd'], line) self.add_to_log(ps['container_name'], f"stdout: {line}", ps['cmd']) if ps['read-notes-stdout']: if note := self.__notes_helper.parse_note(line): self.__notes_helper.add_note({'note': note[1], 'detail_name': ps['detail_name'], 'timestamp': note[0]}) + + if ps['read-sci-stdout']: + if match := re.findall(r'GMT_SCI_R=(\d+)', line): + self._sci['R'] += int(match[0]) if stderr: stderr = stderr.splitlines() for line in stderr: @@ -1071,18 +1108,32 @@ def store_phases(self): def read_container_logs(self): print(TerminalColors.HEADER, '\nCapturing container logs', TerminalColors.ENDC) - for container_name in self.__containers.values(): + for container_id, container_info in self.__containers.items(): + + stderr_behaviour = stdout_behaviour = subprocess.DEVNULL + if container_info['log-stdout'] is True: + stdout_behaviour = subprocess.PIPE + if container_info['log-stderr'] is True: + stderr_behaviour = subprocess.PIPE + + log = subprocess.run( - ['docker', 'logs', '-t', container_name], + ['docker', 'logs', '-t', container_id], check=True, encoding='UTF-8', - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=stdout_behaviour, + stderr=stderr_behaviour, ) + if log.stdout: - self.add_to_log(container_name, f"stdout: {log.stdout}") + self.add_to_log(container_id, f"stdout: {log.stdout}") + if container_info['read-sci-stdout']: + for line in log.stdout.splitlines(): + if match := re.findall(r'GMT_SCI_R=(\d+)', line): + self._sci['R'] += int(match[0]) + if log.stderr: - self.add_to_log(container_name, f"stderr: {log.stderr}") + self.add_to_log(container_id, f"stderr: {log.stderr}") def save_stdout_logs(self): print(TerminalColors.HEADER, '\nSaving logs to DB', TerminalColors.ENDC) @@ -1105,8 +1156,8 @@ def cleanup(self): metric_provider.stop_profiling() print('Stopping containers') - for container_name in self.__containers.values(): - subprocess.run(['docker', 'rm', '-f', container_name], check=True, stderr=subprocess.DEVNULL) + for container_id in self.__containers: + subprocess.run(['docker', 'rm', '-f', container_id], check=True, stderr=subprocess.DEVNULL) print('Removing network') for network_name in self.__networks: @@ -1277,7 +1328,8 @@ def run(self): parser.add_argument('--skip-unsafe', action='store_true', help='Skip unsafe volume bindings, ports and complex environment vars') parser.add_argument('--skip-config-check', action='store_true', help='Skip checking the configuration') parser.add_argument('--verbose-provider-boot', action='store_true', help='Boot metric providers gradually') - parser.add_argument('--full-docker-prune', action='store_true', help='Prune all images and build caches on the system') + parser.add_argument('--full-docker-prune', action='store_true', help='Stop and remove all containers, build caches, volumes and images on the system') + parser.add_argument('--docker-prune', action='store_true', help='Prune all unassociated build caches, networks volumes and stopped containers on the system') parser.add_argument('--dry-run', action='store_true', help='Removes all sleeps. Resulting measurement data will be skewed.') parser.add_argument('--dev-repeat-run', action='store_true', help='Checks if a docker image is already in the local cache and will then not build it. Also doesn\'t clear the images after a run') parser.add_argument('--print-logs', action='store_true', help='Prints the container and process logs to stdout') @@ -1294,6 +1346,16 @@ def run(self): error_helpers.log_error('--allow-unsafe and skip--unsafe in conjuction is not possible') sys.exit(1) + if args.dev_repeat_run and (args.docker_prune or args.full_docker_prune): + parser.print_help() + error_helpers.log_error('--dev-repeat-run blocks pruning docker images. Combination is not allowed') + sys.exit(1) + + if args.full_docker_prune and GlobalConfig().config['postgresql']['host'] == 'green-coding-postgres-container': + parser.print_help() + error_helpers.log_error('--full-docker-prune is set while your database host is "green-coding-postgres-container".\nThe switch is only for remote measuring machines. It would stop the GMT images itself when running locally') + sys.exit(1) + if args.name is None: parser.print_help() error_helpers.log_error('Please supply --name') @@ -1337,7 +1399,7 @@ def run(self): no_file_cleanup=args.no_file_cleanup, skip_config_check =args.skip_config_check, skip_unsafe=args.skip_unsafe,verbose_provider_boot=args.verbose_provider_boot, full_docker_prune=args.full_docker_prune, dry_run=args.dry_run, - dev_repeat_run=args.dev_repeat_run) + dev_repeat_run=args.dev_repeat_run, docker_prune=args.docker_prune) try: runner.run() # Start main code @@ -1351,7 +1413,7 @@ def run(self): # get all the metrics from the measurements table grouped by metric # loop over them issueing separate queries to the DB from phase_stats import build_and_store_phase_stats - build_and_store_phase_stats(project_id) + build_and_store_phase_stats(project_id, runner._sci) print(TerminalColors.OKGREEN,'\n\n####################################################################################') diff --git a/test/api/test_api.py b/test/api/test_api.py index f351e88f1..d2e2d274c 100644 --- a/test/api/test_api.py +++ b/test/api/test_api.py @@ -47,7 +47,7 @@ def test_get_projects(cleanup_projects): pid = DB().fetch_one('INSERT INTO "projects" ("name","uri","email","last_run","created_at") \ VALUES \ (%s,%s,\'manual\',NULL,NOW()) RETURNING id;', params=(project_name, uri))[0] - response = requests.get(f"{API_URL}/v1/projects", timeout=15) + response = requests.get(f"{API_URL}/v1/projects?repo=&filename=", timeout=15) res_json = response.json() assert response.status_code == 200 assert res_json['data'][0][0] == str(pid) diff --git a/tools/client.py b/tools/client.py index bf7cc8c64..2313470a3 100644 --- a/tools/client.py +++ b/tools/client.py @@ -9,7 +9,7 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)) + '/../lib') -from jobs import get_job, process_job +from jobs import get_job, process_job, handle_job_exception from global_config import GlobalConfig from db import DB @@ -19,7 +19,7 @@ # We currently have this dynamically as it will probably change quite a bit STATUS_LIST = ['job_no', 'job_start', 'job_error', 'job_end', 'cleanup_start', 'cleanup_stop'] - +# pylint: disable=redefined-outer-name def set_status(status_code, data=None, project_id=None): if status_code not in STATUS_LIST: raise ValueError(f"Status code not valid: '{status_code}'. Should be in: {STATUS_LIST}") @@ -33,7 +33,7 @@ def set_status(status_code, data=None, project_id=None): DB().query(query=query, params=params) - +# pylint: disable=broad-exception-caught if __name__ == '__main__': while True: @@ -49,6 +49,7 @@ def set_status(status_code, data=None, project_id=None): process_job(*job) except Exception as exc: set_status('job_error', str(exc), project_id) + handle_job_exception(exc, project_id) else: set_status('job_end', '', project_id) diff --git a/tools/jobs.py b/tools/jobs.py index b1db5eb30..03f882335 100644 --- a/tools/jobs.py +++ b/tools/jobs.py @@ -2,6 +2,7 @@ import sys import os import faulthandler +from datetime import datetime faulthandler.enable() # will catch segfaults and write to STDERR @@ -78,12 +79,12 @@ def get_project(project_id): return data -def process_job(job_id, job_type, project_id, skip_config_check=False, full_docker_prune=False): +def process_job(job_id, job_type, project_id, skip_config_check=False, docker_prune=True, full_docker_prune=False): try: if job_type == 'email': _do_email_job(job_id, project_id) elif job_type == 'project': - _do_project_job(job_id, project_id, skip_config_check, full_docker_prune) + _do_project_job(job_id, project_id, skip_config_check, docker_prune, full_docker_prune) else: raise RuntimeError( f"Job w/ id {job_id} has unknown type: {job_type}.") @@ -106,7 +107,7 @@ def _do_email_job(job_id, project_id): # should not be called without enclosing try-except block -def _do_project_job(job_id, project_id, skip_config_check=False, full_docker_prune=False): +def _do_project_job(job_id, project_id, skip_config_check=False, docker_prune=False, full_docker_prune=False): check_job_running('project', job_id) [_, uri, _, branch, filename] = get_project(project_id) @@ -120,16 +121,31 @@ def _do_project_job(job_id, project_id, skip_config_check=False, full_docker_pru skip_unsafe=True, skip_config_check=skip_config_check, full_docker_prune=full_docker_prune, + docker_prune=docker_prune, ) try: # Start main code. Only URL is allowed for cron jobs runner.run() - build_and_store_phase_stats(project_id) + build_and_store_phase_stats(project_id, runner._sci) insert_job('email', project_id=project_id) delete_job(job_id) except Exception as exc: raise exc +# pylint: disable=redefined-outer-name +def handle_job_exception(exce, p_id): + project_name = None + client_mail = None + if p_id: + [project_name, _, client_mail, _, _] = get_project(p_id) + + error_helpers.log_error('Base exception occurred in jobs.py: ', exce) + email_helpers.send_error_email(GlobalConfig().config['admin']['email'], error_helpers.format_error( + 'Base exception occurred in jobs.py: ', exce), project_id=p_id, name=project_name) + + # reduced error message to client + if client_mail and GlobalConfig().config['admin']['email'] != client_mail: + email_helpers.send_error_email(client_mail, exce, project_id=p_id, name=project_name) if __name__ == '__main__': #pylint: disable=broad-except,invalid-name @@ -141,7 +157,8 @@ def _do_project_job(job_id, project_id, skip_config_check=False, full_docker_pru parser.add_argument('type', help='Select the operation mode.', choices=['email', 'project']) parser.add_argument('--config-override', type=str, help='Override the configuration file with the passed in yml file. Must be located in the same directory as the regular configuration file. Pass in only the name.') parser.add_argument('--skip-config-check', action='store_true', default=False, help='Skip checking the configuration') - parser.add_argument('--full-docker-prune', action='store_true', default=False, help='Prune all images and build caches on the system') + parser.add_argument('--full-docker-prune', action='store_true', help='Stop and remove all containers, build caches, volumes and images on the system') + parser.add_argument('--docker-prune', action='store_true', help='Prune all unassociated build caches, networks volumes and stopped containers on the system') args = parser.parse_args() # script will exit if type is not present @@ -162,21 +179,10 @@ def _do_project_job(job_id, project_id, skip_config_check=False, full_docker_pru try: job = get_job(args.type) if (job is None or job == []): - print('No job to process. Exiting') + print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 'No job to process. Exiting') sys.exit(0) p_id = job[2] - process_job(job[0], job[1], job[2], args.skip_config_check, args.full_docker_prune) + process_job(job[0], job[1], job[2], args.skip_config_check, args.docker_prune, args.full_docker_prune) print('Successfully processed jobs queue item.') except Exception as exce: - project_name = None - client_mail = None - if p_id: - [project_name, _, client_mail, _, _] = get_project(p_id) - - error_helpers.log_error('Base exception occurred in jobs.py: ', exce) - email_helpers.send_error_email(GlobalConfig().config['admin']['email'], error_helpers.format_error( - 'Base exception occurred in jobs.py: ', exce), project_id=p_id, name=project_name) - - # reduced error message to client - if client_mail and GlobalConfig().config['admin']['email'] != client_mail: - email_helpers.send_error_email(client_mail, exce, project_id=p_id, name=project_name) + handle_job_exception(exce, p_id) diff --git a/tools/phase_stats.py b/tools/phase_stats.py index 1f3aab6e5..1f1f51fce 100644 --- a/tools/phase_stats.py +++ b/tools/phase_stats.py @@ -2,8 +2,8 @@ from io import StringIO import sys import os +import decimal import faulthandler - faulthandler.enable() # will catch segfaults and write to STDERR CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -11,12 +11,15 @@ sys.path.append(f"{CURRENT_DIR}/../lib") from db import DB +from global_config import GlobalConfig def generate_csv_line(project_id, metric, detail_name, phase_name, value, value_type, max_value, min_value, unit): return f"{project_id},{metric},{detail_name},{phase_name},{round(value)},{value_type},{round(max_value) if max_value is not None else ''},{round(min_value) if min_value is not None else ''},{unit},NOW()\n" -def build_and_store_phase_stats(project_id): +def build_and_store_phase_stats(project_id, sci=None): + config = GlobalConfig().config + query = """ SELECT metric, unit, detail_name FROM measurements @@ -38,6 +41,8 @@ def build_and_store_phase_stats(project_id): for idx, phase in enumerate(phases[0]): network_io_bytes_total = [] # reset; # we use array here and sum later, because checking for 0 alone not enough + machine_co2 = None # reset + select_query = """ SELECT SUM(value), MAX(value), MIN(value), AVG(value), COUNT(value) FROM measurements @@ -99,7 +104,7 @@ def build_and_store_phase_stats(project_id): csv_buffer.write(generate_csv_line(project_id, f"{metric.replace('_energy_', '_power_')}", detail_name, f"{idx:03}_{phase['name']}", power_sum, 'MEAN', power_max, power_min, 'mW')) if metric.endswith('_machine'): - machine_co2 = ((value_sum / 3_600) * 475) + machine_co2 = (value_sum / 3_600) * config['sci']['I'] csv_buffer.write(generate_csv_line(project_id, f"{metric.replace('_energy_', '_co2_')}", detail_name, f"{idx:03}_{phase['name']}", machine_co2, 'TOTAL', None, None, 'ug')) @@ -114,11 +119,21 @@ def build_and_store_phase_stats(project_id): network_io_in_mJ = network_io_in_kWh * 3_600_000_000 csv_buffer.write(generate_csv_line(project_id, 'network_energy_formula_global', '[FORMULA]', f"{idx:03}_{phase['name']}", network_io_in_mJ, 'TOTAL', None, None, 'mJ')) # co2 calculations - network_io_co2_in_ug = network_io_in_kWh * 475 * 1_000_000 + network_io_co2_in_ug = network_io_in_kWh * config['sci']['I'] * 1_000_000 csv_buffer.write(generate_csv_line(project_id, 'network_co2_formula_global', '[FORMULA]', f"{idx:03}_{phase['name']}", network_io_co2_in_ug, 'TOTAL', None, None, 'ug')) - # also create the phase time metric - csv_buffer.write(generate_csv_line(project_id, 'phase_time_syscall_system', '[SYSTEM]', f"{idx:03}_{phase['name']}", phase['end']-phase['start'], 'TOTAL', None, None, 'us')) + duration = phase['end']-phase['start'] + csv_buffer.write(generate_csv_line(project_id, 'phase_time_syscall_system', '[SYSTEM]', f"{idx:03}_{phase['name']}", duration, 'TOTAL', None, None, 'us')) + + duration_in_years = duration / (1_000_000 * 60 * 60 * 24 * 365) + embodied_carbon_share_g = (duration_in_years / (config['sci']['EL']) ) * config['sci']['TE'] * config['sci']['RS'] + embodied_carbon_share_ug = decimal.Decimal(embodied_carbon_share_g * 1_000_000) + csv_buffer.write(generate_csv_line(project_id, 'embodied_carbon_share_machine', '[SYSTEM]', f"{idx:03}_{phase['name']}", embodied_carbon_share_ug, 'TOTAL', None, None, 'ug')) + + if phase['name'] == '[RUNTIME]' and machine_co2 is not None and sci is not None \ + and sci.get('R', None) is not None and sci['R'] != 0: + csv_buffer.write(generate_csv_line(project_id, 'software_carbon_intensity_global', '[SYSTEM]', f"{idx:03}_{phase['name']}", (machine_co2 + embodied_carbon_share_ug) / sci['R'], 'TOTAL', None, None, f"ugCO2e/{sci['R_d']}")) + csv_buffer.seek(0) # Reset buffer position to the beginning DB().copy_from(
${value}${label}${escapeString(value)}${escapeString(label)}${run_link_node}${dateToYMD(new Date(created_at))}${short_hash}${cpu}${duration} seconds${dateToYMD(new Date(created_at))}${escapeString(short_hash)}${escapeString(cpu)}${escapeString(duration)} seconds
Repository:${repo_link_node}
Branch:${url_params.get('branch')}
Workflow:${url_params.get('workflow')}
Branch:${escapeString(url_params.get('branch'))}
Workflow:${escapeString(url_params.get('workflow'))}
${key}${phase_stats_data['common_info'][key]}
${metric_data.clean_name}${metric_data.source}${scope}${detail_data.name}${value}${unit}${std_dev_text_table}${max_value}${min_value}${metric_data.clean_name}${metric_data.source}${scope}${detail_name}${metric_data.type}${value}${unit}${std_dev_text_table}${max_value}${min_value}${max_mean_value}${min_mean_value}${metric_data.clean_name}${metric_data.source}${scope}${detail_name}${metric_data.type}${value}${unit}${max_value}${min_value}${metric_data.clean_name} ${metric_data.source} ${scope}${detail_data_array[0].name}${detail_name}${metric_data.type} ${value_1} ${value_2} ${unit}${extra_label}MetricSourceScopeDetail Name${replaceRepoIcon(keys[0])}${replaceRepoIcon(keys[1])}UnitChangeSignificant (T-Test)MetricSourceScopeDetail NameValueUnitStdDevMAXMINMetricSourceScopeDetail NameType${replaceRepoIcon(comparison_keys[0])}${replaceRepoIcon(comparison_keys[1])}UnitChangeSignificant (T-Test)MetricSourceScopeDetail NameTypeValueUnitStdDevMax.Min.Max. (of means)Min. (of means)MetricSourceScopeDetail NameTypeValueUnitMax.Min.