diff --git a/.github/workflows/tests-bare-metal-main.yml b/.github/workflows/tests-bare-metal-main.yml index 824e056ec..92bd8cc2f 100644 --- a/.github/workflows/tests-bare-metal-main.yml +++ b/.github/workflows/tests-bare-metal-main.yml @@ -35,7 +35,7 @@ jobs: uses: actions/checkout@v4 with: ref: 'main' - submodules: 'true' + submodules: 'false' - name: Eco CI Energy Estimation - Initialize uses: green-coding-solutions/eco-ci-energy-estimation@main diff --git a/.github/workflows/tests-eco-ci-energy-estimation.yaml b/.github/workflows/tests-eco-ci-energy-estimation.yaml index 517437343..95af24b58 100644 --- a/.github/workflows/tests-eco-ci-energy-estimation.yaml +++ b/.github/workflows/tests-eco-ci-energy-estimation.yaml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v4 with: ref: 'main' - submodules: 'true' + submodules: 'false' - name: Eco CI Energy Estimation - Initialize uses: green-coding-solutions/eco-ci-energy-estimation@testing diff --git a/.github/workflows/tests-vm-mac.yml b/.github/workflows/tests-vm-mac.yml index 1b856060b..84dff4d93 100644 --- a/.github/workflows/tests-vm-mac.yml +++ b/.github/workflows/tests-vm-mac.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.ref }} - submodules: 'true' + submodules: 'false' - if: ${{ github.event_name == 'workflow_dispatch' || steps.check-date.outputs.should_run == 'true'}} name: 'Setup, Run, and Teardown Tests' diff --git a/.github/workflows/tests-vm-main.yml b/.github/workflows/tests-vm-main.yml index d2e49a976..9cd63ec97 100644 --- a/.github/workflows/tests-vm-main.yml +++ b/.github/workflows/tests-vm-main.yml @@ -36,7 +36,7 @@ jobs: uses: actions/checkout@v4 with: ref: 'main' - submodules: 'true' + submodules: 'false' - if: ${{ github.event_name == 'workflow_dispatch' || steps.check-date.outputs.should_run == 'true'}} name: 'Setup, Run, and Teardown Tests' diff --git a/.github/workflows/tests-vm-pr.yml b/.github/workflows/tests-vm-pr.yml index 8da9eaa67..fcb8fa912 100644 --- a/.github/workflows/tests-vm-pr.yml +++ b/.github/workflows/tests-vm-pr.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.ref }} - submodules: 'true' + submodules: 'false' - name: 'Setup, Run, and Teardown Tests' diff --git a/.gitignore b/.gitignore index 39638a75e..7a2e1adc0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ static-binary /docker/test-compose.yml /tests/structure.sql tools/sgx_enable +/tests/test-config.yml \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index aed73413c..3af8fb727 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "metric_providers/psu/energy/ac/xgboost/machine/model"] path = metric_providers/psu/energy/ac/xgboost/machine/model url = https://github.com/green-coding-solutions/spec-power-model +[submodule "ee"] + path = ee + url = git@github.com:green-coding-solutions/gmt-enterprise.git diff --git a/api/api_helpers.py b/api/api_helpers.py index b98effc7f..16ff15741 100644 --- a/api/api_helpers.py +++ b/api/api_helpers.py @@ -2,19 +2,18 @@ import faulthandler faulthandler.enable(file=sys.__stderr__) # will catch segfaults and write to stderr +from urllib.parse import urlparse + from functools import cache from html import escape as html_escape -import re -import math import typing -import ipaddress -import json import uuid from starlette.background import BackgroundTask from fastapi.responses import ORJSONResponse +from fastapi import Depends, Request, HTTPException +from fastapi.security import APIKeyHeader import numpy as np -import requests import scipy.stats from pydantic import BaseModel @@ -22,6 +21,8 @@ from lib.global_config import GlobalConfig from lib.db import DB from lib import error_helpers +from lib.user import User, UserAuthenticationError +from lib.secure_variable import SecureVariable import redis from enum import Enum @@ -632,191 +633,33 @@ def __init__( self.content = content super().__init__(content, status_code, headers, media_type, background) -# The decorator will not work between requests, so we are not prone to stale data over time -@cache -def get_geo(ip): - - ip_obj = ipaddress.ip_address(ip) # may raise a ValueError - if ip_obj.is_private: - error_helpers.log_error(f"Private IP was submitted to get_geo {ip}. This is normal in development, but should not happen in production.") - return('52.53721666833642', '13.424863870661927') - - query = "SELECT ip_address, data FROM ip_data WHERE created_at > NOW() - INTERVAL '24 hours' AND ip_address=%s;" - db_data = DB().fetch_all(query, (ip,)) - - if db_data is not None and len(db_data) != 0: - return (db_data[0][1].get('latitude'), db_data[0][1].get('longitude')) - - latitude, longitude = get_geo_ip_api_com(ip) - - if not latitude: - latitude, longitude = get_geo_ipapi_co(ip) - if not latitude: - latitude, longitude = get_geo_ip_ipinfo(ip) - if not latitude: - raise RuntimeError(f"Could not get Geo-IP for {ip} after 3 tries") - - return (latitude, longitude) - - -def get_geo_ipapi_co(ip): - - print(f"Accessing https://ipapi.co/{ip}/json/") - try: - response = requests.get(f"https://ipapi.co/{ip}/json/", timeout=10) - except Exception as exc: #pylint: disable=broad-exception-caught - error_helpers.log_error('API request to ipapi.co failed ...', exception=exc) - return (False, False) - - if response.status_code == 200: - resp_data = response.json() - - if 'error' in resp_data or 'latitude' not in resp_data or 'longitude' not in resp_data: - return (None, None) - - resp_data['source'] = 'ipapi.co' - - query = "INSERT INTO ip_data (ip_address, data) VALUES (%s, %s)" - DB().query(query=query, params=(ip, json.dumps(resp_data))) - return (resp_data.get('latitude'), resp_data.get('longitude')) +header_scheme = APIKeyHeader( + name='X-Authentication', + scheme_name='Header', + description='Authentication key - See https://docs.green-coding.io/authentication', + auto_error=False +) - error_helpers.log_error(f"Could not get Geo-IP from ipapi.co for {ip}. Trying next ...", response=response) - - return (False, False) - -def get_geo_ip_api_com(ip): - - print(f"Accessing http://ip-api.com/json/{ip}") - try: - response = requests.get(f"http://ip-api.com/json/{ip}", timeout=10) - except Exception as exc: #pylint: disable=broad-exception-caught - error_helpers.log_error('API request to ip-api.com failed ...', exception=exc) - return (False, False) - - if response.status_code == 200: - resp_data = response.json() - - if ('status' in resp_data and resp_data.get('status') == 'fail') or 'lat' not in resp_data or 'lon' not in resp_data: - return (None, None) - - resp_data['latitude'] = resp_data.get('lat') - resp_data['longitude'] = resp_data.get('lon') - resp_data['source'] = 'ip-api.com' - - query = "INSERT INTO ip_data (ip_address, data) VALUES (%s, %s)" - DB().query(query=query, params=(ip, json.dumps(resp_data))) - - return (resp_data.get('latitude'), resp_data.get('longitude')) - - error_helpers.log_error(f"Could not get Geo-IP from ip-api.com for {ip}. Trying next ...", response=response) - - return (False, False) - -def get_geo_ip_ipinfo(ip): - - print(f"Accessing https://ipinfo.io/{ip}/json") +def authenticate(authentication_token=Depends(header_scheme), request: Request = None): + parsed_url = urlparse(str(request.url)) try: - response = requests.get(f"https://ipinfo.io/{ip}/json", timeout=10) - except Exception as exc: #pylint: disable=broad-exception-caught - error_helpers.log_error('API request to ipinfo.io failed ...', exception=exc) - return (False, False) - - if response.status_code == 200: - resp_data = response.json() - - if 'bogon' in resp_data or 'loc' not in resp_data: - return (None, None) - - lat_lng = resp_data.get('loc').split(',') + if not authentication_token or authentication_token.strip() == '': # Note that if no token is supplied this will authenticate as the DEFAULT user, which in FOSS systems has full capabilities + authentication_token = 'DEFAULT' - resp_data['latitude'] = lat_lng[0] - resp_data['longitude'] = lat_lng[1] - resp_data['source'] = 'ipinfo.io' + user = User.authenticate(SecureVariable(authentication_token)) - query = "INSERT INTO ip_data (ip_address, data) VALUES (%s, %s)" - DB().query(query=query, params=(ip, json.dumps(resp_data))) + if not user.can_use_route(parsed_url.path): + raise HTTPException(status_code=401, detail="Route not allowed") from UserAuthenticationError - return (resp_data.get('latitude'), resp_data.get('longitude')) + if not user.has_api_quota(parsed_url.path): + raise HTTPException(status_code=401, detail="Quota exceeded") from UserAuthenticationError - error_helpers.log_error(f"Could not get Geo-IP from ipinfo.io for {ip}. Trying next ...", response=response) - - return (False, False) - -# The decorator will not work between requests, so we are not prone to stale data over time -@cache -def get_carbon_intensity(latitude, longitude): - - if latitude is None or longitude is None: - return None - - query = "SELECT latitude, longitude, data FROM carbon_intensity WHERE created_at > NOW() - INTERVAL '1 hours' AND latitude=%s AND longitude=%s;" - db_data = DB().fetch_all(query, (latitude, longitude)) - - if db_data is not None and len(db_data) != 0: - return db_data[0][2].get('carbonIntensity') - - if not (electricitymaps_token := GlobalConfig().config.get('electricity_maps_token')): - raise ValueError('You need to specify an electricitymap token in the config!') - - if electricitymaps_token == 'testing': - # If we are running tests we always return 1000 - return 1000 - - headers = {'auth-token': electricitymaps_token } - params = {'lat': latitude, 'lon': longitude } - - response = requests.get('https://api.electricitymap.org/v3/carbon-intensity/latest', params=params, headers=headers, timeout=10) - print(f"Accessing electricitymap with {latitude} {longitude}") - if response.status_code == 200: - resp_data = response.json() - query = "INSERT INTO carbon_intensity (latitude, longitude, data) VALUES (%s, %s, %s)" - DB().query(query=query, params=(latitude, longitude, json.dumps(resp_data))) - - return resp_data.get('carbonIntensity') - - error_helpers.log_error(f"Could not get carbon intensity from Electricitymaps.org for {params}", response=response) - - return None - -def carbondb_add(connecting_ip, data, source, user_id): - - query = ''' - INSERT INTO carbondb_data_raw - ("type", "project", "machine", "source", "tags","time","energy_kwh","carbon_kg","carbon_intensity_g","latitude","longitude","ip_address","user_id","created_at") - VALUES - (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW()) - ''' - - used_client_ip = data.get('ip', None) # An ip has been given with the data. We prioritize that - if used_client_ip is None: - used_client_ip = connecting_ip - - carbon_intensity_g_per_kWh = data.get('carbon_intensity_g', None) - - if carbon_intensity_g_per_kWh is not None: # we need this check explicitely as we want to allow 0 as possible value - latitude = None # no use to derive if we get supplied data. We rather indicate with NULL that user supplied - longitude = None # no use to derive if we get supplied data. We rather indicate with NULL that user supplied - else: - latitude, longitude = get_geo(used_client_ip) # cached - carbon_intensity_g_per_kWh = get_carbon_intensity(latitude, longitude) # cached - - energy_J = float(data['energy_uj']) / 1e6 - energy_kWh = energy_J / (3_600*1_000) - carbon_kg = (energy_kWh * carbon_intensity_g_per_kWh)/1_000 - - DB().query( - query=query, - params=( - data['type'], - data['project'], data['machine'], source, data['tags'], data['time'], energy_kWh, carbon_kg, carbon_intensity_g_per_kWh, latitude, longitude, used_client_ip, user_id)) - - -def validate_carbondb_params(param, elements: list): - for el in elements: - if not re.fullmatch(r'[A-Za-z0-9\._-]+', el): - raise ValueError(f"Parameter for '{param}' may only contain A-Za-z0-9._- characters and no spaces. Was: {el}") + user.deduct_api_quota(parsed_url.path, 1) + except UserAuthenticationError: + raise HTTPException(status_code=401, detail="Invalid token") from UserAuthenticationError + return user def get_connecting_ip(request): connecting_ip = request.headers.get("x-forwarded-for") @@ -825,64 +668,3 @@ def get_connecting_ip(request): return connecting_ip.split(",")[0] return request.client.host - - -def replace_nan_with_zero(obj): - if isinstance(obj, dict): - for k, v in obj.items(): - if isinstance(v, (dict, list)): - replace_nan_with_zero(v) - elif isinstance(v, float) and math.isnan(v): - obj[k] = 0 - elif isinstance(obj, list): - for i, item in enumerate(obj): - if isinstance(item, (dict, list)): - replace_nan_with_zero(item) - elif isinstance(item, float) and math.isnan(item): - obj[i] = 0 - return obj - -# Refactor have this in the Pydantic model? -# https://github.com/green-coding-solutions/green-metrics-tool/issues/907 -def validate_hog_measurement_data(data): - required_top_level_fields = [ - 'coalitions', 'all_tasks', 'elapsed_ns', 'processor', 'thermal_pressure' - ] - for field in required_top_level_fields: - if field not in data: - raise ValueError(f"Missing required field: {field}") - - # Validate 'coalitions' structure - if not isinstance(data['coalitions'], list): - raise ValueError("Expected 'coalitions' to be a list") - - for coalition in data['coalitions']: - required_coalition_fields = [ - 'name', 'tasks', 'energy_impact_per_s', 'cputime_ms_per_s', - 'diskio_bytesread', 'diskio_byteswritten', 'intr_wakeups', 'idle_wakeups' - ] - for field in required_coalition_fields: - if field not in coalition: - raise ValueError(f"Missing required coalition field: {field}") - if field == 'tasks' and not isinstance(coalition['tasks'], list): - raise ValueError(f"Expected 'tasks' to be a list in coalition: {coalition['name']}") - - # Validate 'all_tasks' structure - if 'energy_impact_per_s' not in data['all_tasks']: - raise ValueError("Missing 'energy_impact_per_s' in 'all_tasks'") - - # Validate 'processor' structure based on the processor type - processor_fields = data['processor'].keys() - if 'ane_energy' in processor_fields: - required_processor_fields = ['combined_power', 'cpu_energy', 'gpu_energy', 'ane_energy'] - elif 'package_joules' in processor_fields: - required_processor_fields = ['package_joules', 'cpu_joules', 'igpu_watts'] - else: - raise ValueError("Unknown processor type") - - for field in required_processor_fields: - if field not in processor_fields: - raise ValueError(f"Missing required processor field: {field}") - - # All checks passed - return True diff --git a/api/eco_ci.py b/api/eco_ci.py new file mode 100644 index 000000000..b6815915b --- /dev/null +++ b/api/eco_ci.py @@ -0,0 +1,267 @@ +from datetime import date + +from fastapi import APIRouter +from fastapi import Request, Response, Depends +from fastapi.responses import ORJSONResponse + +from api.api_helpers import authenticate, html_escape_multi, get_connecting_ip, rescale_energy_value +from api.object_specifications import CI_Measurement_Old, CI_Measurement + +import anybadge + +from xml.sax.saxutils import escape as xml_escape + +from lib import error_helpers +from lib.user import User +from lib.db import DB + +router = APIRouter() + + +@router.post('/v1/ci/measurement/add') +async def post_ci_measurement_add_deprecated( + request: Request, + measurement: CI_Measurement_Old, + user: User = Depends(authenticate) # pylint: disable=unused-argument + ): + + measurement = html_escape_multi(measurement) + + used_client_ip = get_connecting_ip(request) + + co2i_transformed = int(measurement.co2i) if measurement.co2i else None + + co2eq_transformed = int(float(measurement.co2eq)*1000000) if measurement.co2eq else None + + query = ''' + INSERT INTO + ci_measurements (energy_uj, + repo, + branch, + workflow_id, + run_id, + label, + source, + cpu, + commit_hash, + duration_us, + cpu_util_avg, + workflow_name, + lat, + lon, + city, + carbon_intensity_g, + carbon_ug, + filter_type, + filter_project, + filter_machine, + filter_tags, + user_id, + ip_address + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ''' + + params = ( measurement.energy_value*1000, measurement.repo, measurement.branch, + measurement.workflow, measurement.run_id, measurement.label, measurement.source, measurement.cpu, + measurement.commit_hash, measurement.duration*1000000, measurement.cpu_util_avg, measurement.workflow_name, + measurement.lat, measurement.lon, measurement.city, co2i_transformed, co2eq_transformed, + 'machine.ci', 'CI/CD', 'unknown', [], + user._id, used_client_ip) + + + DB().query(query=query, params=params) + + if measurement.energy_value <= 1 or (measurement.co2eq and co2eq_transformed <= 1): + error_helpers.log_error( + 'Extremely small energy budget was submitted to old Eco-CI API', + measurement=measurement + ) + + return Response(status_code=204) + + +@router.post('/v2/ci/measurement/add') +async def post_ci_measurement_add( + request: Request, + measurement: CI_Measurement, + user: User = Depends(authenticate) # pylint: disable=unused-argument + ): + + measurement = html_escape_multi(measurement) + + params = [measurement.energy_uj, measurement.repo, measurement.branch, + measurement.workflow, measurement.run_id, measurement.label, measurement.source, measurement.cpu, + measurement.commit_hash, measurement.duration_us, measurement.cpu_util_avg, measurement.workflow_name, + measurement.lat, measurement.lon, measurement.city, measurement.carbon_intensity_g, measurement.carbon_ug, + measurement.filter_type, measurement.filter_project, measurement.filter_machine] + + tags_replacer = ' ARRAY[]::text[] ' + if measurement.filter_tags: + tags_replacer = f" ARRAY[{','.join(['%s']*len(measurement.filter_tags))}] " + params = params + measurement.filter_tags + + used_client_ip = measurement.ip # If an ip has been given with the data. We prioritize that + if used_client_ip is None: + used_client_ip = get_connecting_ip(request) + + params.append(used_client_ip) + params.append(user._id) + + query = f""" + INSERT INTO + ci_measurements (energy_uj, + repo, + branch, + workflow_id, + run_id, + label, + source, + cpu, + commit_hash, + duration_us, + cpu_util_avg, + workflow_name, + lat, + lon, + city, + carbon_intensity_g, + carbon_ug, + filter_type, + filter_project, + filter_machine, + filter_tags, + ip_address, + user_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + {tags_replacer}, + %s, %s) + + """ + + DB().query(query=query, params=params) + + if measurement.energy_uj <= 1 or (measurement.carbon_ug and measurement.carbon_ug <= 1): + error_helpers.log_error( + 'Extremely small energy budget was submitted to Eco-CI API', + measurement=measurement + ) + + return Response(status_code=204) + +@router.get('/v1/ci/measurements') +async def get_ci_measurements(repo: str, branch: str, workflow: str, start_date: date, end_date: date): + + query = """ + SELECT energy_uj, run_id, created_at, label, cpu, commit_hash, duration_us, source, cpu_util_avg, + (SELECT workflow_name FROM ci_measurements AS latest_workflow + WHERE latest_workflow.repo = ci_measurements.repo + AND latest_workflow.branch = ci_measurements.branch + AND latest_workflow.workflow_id = ci_measurements.workflow_id + ORDER BY latest_workflow.created_at DESC + LIMIT 1) AS workflow_name, + lat, lon, city, carbon_intensity_g, carbon_ug + FROM ci_measurements + WHERE + repo = %s AND branch = %s AND workflow_id = %s + AND DATE(created_at) >= TO_DATE(%s, 'YYYY-MM-DD') + AND DATE(created_at) <= TO_DATE(%s, 'YYYY-MM-DD') + ORDER BY run_id ASC, created_at ASC + """ + params = (repo, branch, workflow, str(start_date), str(end_date)) + data = DB().fetch_all(query, params=params) + + if data is None or data == []: + return Response(status_code=204) # No-Content + + return ORJSONResponse({'success': True, 'data': data}) + +@router.get('/v1/ci/repositories') +async def get_ci_repositories(repo: str | None = None, sort_by: str = 'name'): + + params = [] + query = """ + SELECT repo, source, MAX(created_at) as last_run + FROM ci_measurements + WHERE 1=1 + """ + + if repo: # filter is currently not used, but may be a feature in the future + query = f"{query} AND ci_measurements.repo = %s \n" + params.append(repo) + + query = f"{query} GROUP BY repo, source" + + if sort_by == 'date': + query = f"{query} ORDER BY last_run DESC" + else: + query = f"{query} ORDER BY repo ASC" + + data = DB().fetch_all(query, params=tuple(params)) + if data is None or data == []: + return Response(status_code=204) # No-Content + + return ORJSONResponse({'success': True, 'data': data}) # no escaping needed, as it happend on ingest + + +@router.get('/v1/ci/runs') +async def get_ci_runs(repo: str, sort_by: str = 'name'): + + params = [] + query = """ + SELECT repo, branch, workflow_id, source, MAX(created_at) as last_run, + (SELECT workflow_name FROM ci_measurements AS latest_workflow + WHERE latest_workflow.repo = ci_measurements.repo + AND latest_workflow.branch = ci_measurements.branch + AND latest_workflow.workflow_id = ci_measurements.workflow_id + ORDER BY latest_workflow.created_at DESC + LIMIT 1) AS workflow_name + FROM ci_measurements + WHERE 1=1 + """ + + query = f"{query} AND ci_measurements.repo = %s \n" + params.append(repo) + query = f"{query} GROUP BY repo, branch, workflow_id, source" + + if sort_by == 'date': + query = f"{query} ORDER BY last_run DESC" + else: + query = f"{query} ORDER BY repo ASC" + + data = DB().fetch_all(query, params=tuple(params)) + if data is None or data == []: + return Response(status_code=204) # No-Content + + return ORJSONResponse({'success': True, 'data': data}) # no escaping needed, as it happend on ingest + +@router.get('/v1/ci/badge/get') +async def get_ci_badge_get(repo: str, branch: str, workflow:str): + query = """ + SELECT SUM(energy_uj), MAX(run_id) + FROM ci_measurements + WHERE repo = %s AND branch = %s AND workflow_id = %s + GROUP BY run_id + ORDER BY MAX(created_at) DESC + LIMIT 1 + """ + + params = (repo, branch, workflow) + data = DB().fetch_one(query, params=params) + + if data is None or data == [] or data[1] is None: # special check for data[1] as this is aggregate query which always returns result + return Response(status_code=204) # No-Content + + energy_value = data[0] + + [energy_value, energy_unit] = rescale_energy_value(energy_value, 'uJ') + badge_value= f"{energy_value:.2f} {energy_unit}" + + badge = anybadge.Badge( + label='Energy Used', + 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/main.py b/api/main.py index ad78a4efc..12206e59e 100644 --- a/api/main.py +++ b/api/main.py @@ -4,43 +4,36 @@ import faulthandler faulthandler.enable(file=sys.__stderr__) # will catch segfaults and write to stderr -import zlib -import base64 import orjson -from typing import List from xml.sax.saxutils import escape as xml_escape -from urllib.parse import urlparse from datetime import date -from fastapi import FastAPI, Request, Response, Depends, HTTPException +from fastapi import FastAPI, Request, Response, Depends from fastapi.responses import ORJSONResponse from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import APIKeyHeader from starlette.responses import RedirectResponse from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.datastructures import Headers as StarletteHeaders -from pydantic import ValidationError - import anybadge -from api.object_specifications import Measurement, CI_Measurement_Old, CI_Measurement, HogMeasurement, Software, EnergyData -from api.api_helpers import (ORJSONResponseObjKeep, add_phase_stats_statistics, carbondb_add, determine_comparison_case, +from api import eco_ci +from api.object_specifications import Software +from api.api_helpers import (ORJSONResponseObjKeep, add_phase_stats_statistics, determine_comparison_case, html_escape_multi, get_phase_stats, get_phase_stats_object, is_valid_uuid, rescale_energy_value, get_timeline_query, - get_run_info, get_machine_list, get_artifact, store_artifact, get_connecting_ip, - validate_hog_measurement_data, replace_nan_with_zero) + get_run_info, get_machine_list, get_artifact, store_artifact, + authenticate) from lib.global_config import GlobalConfig from lib.db import DB from lib.diff import get_diffable_row, diff_rows from lib import error_helpers from lib.job.base import Job -from lib.user import User, UserAuthenticationError -from lib.secure_variable import SecureVariable +from lib.user import User from lib.timeline_project import TimelineProject from lib import utils @@ -122,39 +115,12 @@ async def catch_exceptions_middleware(request: Request, call_next): allow_headers=['*'], ) -header_scheme = APIKeyHeader( - name='X-Authentication', - scheme_name='Header', - description='Authentication key - See https://docs.green-coding.io/authentication', - auto_error=False -) - def obfuscate_authentication_token(headers: StarletteHeaders): headers_mut = headers.mutablecopy() if 'X-Authentication' in headers_mut: headers_mut['X-Authentication'] = '****OBFUSCATED****' return headers_mut -def authenticate(authentication_token=Depends(header_scheme), request: Request = None): - parsed_url = urlparse(str(request.url)) - try: - if not authentication_token or authentication_token.strip() == '': # Note that if no token is supplied this will authenticate as the DEFAULT user, which in FOSS systems has full capabilities - authentication_token = 'DEFAULT' - - user = User.authenticate(SecureVariable(authentication_token)) - - if not user.can_use_route(parsed_url.path): - raise HTTPException(status_code=401, detail="Route not allowed") from UserAuthenticationError - - if not user.has_api_quota(parsed_url.path): - raise HTTPException(status_code=401, detail="Quota exceeded") from UserAuthenticationError - - user.deduct_api_quota(parsed_url.path, 1) - - except UserAuthenticationError: - raise HTTPException(status_code=401, detail="Invalid token") from UserAuthenticationError - return user - @app.get('/') async def home(): @@ -630,375 +596,6 @@ async def get_jobs(machine_id: int | None = None, state: str | None = None): return ORJSONResponse({'success': True, 'data': data}) - - -@app.post('/v1/hog/add') -async def hog_add( - measurements: List[HogMeasurement], - user: User = Depends(authenticate), # pylint: disable=unused-argument - ): - - for measurement in measurements: - decoded_data = base64.b64decode(measurement.data) - decompressed_data = zlib.decompress(decoded_data) - measurement_data = orjson.loads(decompressed_data.decode()) # pylint: disable=no-member - - # For some reason we sometimes get NaN in the data. - measurement_data = replace_nan_with_zero(measurement_data) - - #Check if the data is valid, if not this will throw an exception and converted into a request by the middleware - try: - _ = Measurement(**measurement_data) - except (ValidationError, RequestValidationError) as exc: - print('Caught Exception in Measurement()', exc.__class__.__name__, exc) - print('Hog parsing error. Missing expected, but non critical key', str(exc)) - # Output is extremely verbose. Please only turn on if debugging manually - # print(f"Errors are: {exc.errors()}") - - - try: - validate_hog_measurement_data(measurement_data) - except ValueError as exc: - print(f"Caught Exception in validate_hog_measurement_data() {exc.__class__.__name__} {exc}") - raise exc - - coalitions = [] - for coalition in measurement_data['coalitions']: - if coalition['name'] == 'com.googlecode.iterm2' or \ - coalition['name'] == 'com.apple.Terminal' or \ - coalition['name'] == 'com.vix.cron' or \ - coalition['name'].strip() == '': - tmp = coalition['tasks'] - for tmp_el in tmp: - tmp_el['tasks'] = [] - coalitions.extend(tmp) - else: - coalitions.append(coalition) - - # We remove the coalitions as we don't want to save all the data in hog_measurements - del measurement_data['coalitions'] - del measurement.data - - cpu_energy_data = {} - energy_impact = round(measurement_data['all_tasks'].get('energy_impact_per_s') * measurement_data['elapsed_ns'] / 1_000_000_000) - if 'ane_energy' in measurement_data['processor']: - cpu_energy_data = { - 'combined_energy': round(measurement_data['processor'].get('combined_power', 0) * measurement_data['elapsed_ns'] / 1_000_000_000.0), - 'cpu_energy': round(measurement_data['processor'].get('cpu_energy', 0)), - 'gpu_energy': round(measurement_data['processor'].get('gpu_energy', 0)), - 'ane_energy': round(measurement_data['processor'].get('ane_energy', 0)), - 'energy_impact': energy_impact, - } - elif 'package_joules' in measurement_data['processor']: - # Intel processors report in joules/ watts and not mJ - cpu_energy_data = { - 'combined_energy': round(measurement_data['processor'].get('package_joules', 0) * 1_000), - 'cpu_energy': round(measurement_data['processor'].get('cpu_joules', 0) * 1_000), - 'gpu_energy': round(measurement_data['processor'].get('igpu_watts', 0) * measurement_data['elapsed_ns'] / 1_000_000_000.0 * 1_000), - 'ane_energy': 0, - 'energy_impact': energy_impact, - } - else: - raise RequestValidationError("input not valid") - - query = """ - INSERT INTO - hog_measurements ( - time, - machine_uuid, - elapsed_ns, - combined_energy, - cpu_energy, - gpu_energy, - ane_energy, - energy_impact, - thermal_pressure, - settings, - user_id) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """ - params = ( - measurement.time, - measurement.machine_uuid, - measurement_data['elapsed_ns'], - cpu_energy_data['combined_energy'], - cpu_energy_data['cpu_energy'], - cpu_energy_data['gpu_energy'], - cpu_energy_data['ane_energy'], - cpu_energy_data['energy_impact'], - measurement_data['thermal_pressure'], - measurement.settings, - user._id, - ) - - measurement_db_id = DB().fetch_one(query=query, params=params)[0] - - - # Save hog_measurements - for coalition in coalitions: - - if coalition['energy_impact'] < 1.0: - # If the energy_impact is too small we just skip the coalition. - continue - - c_tasks = coalition['tasks'].copy() - del coalition['tasks'] - - c_energy_impact = round((coalition['energy_impact_per_s'] / 1_000_000_000) * measurement_data['elapsed_ns']) - c_cputime_ns = ((coalition['cputime_ms_per_s'] * 1_000_000) / 1_000_000_000) * measurement_data['elapsed_ns'] - - query = """ - INSERT INTO - hog_coalitions ( - measurement, - name, - cputime_ns, - cputime_per, - energy_impact, - diskio_bytesread, - diskio_byteswritten, - intr_wakeups, - idle_wakeups) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """ - params = ( - measurement_db_id, - coalition['name'], - c_cputime_ns, - int(c_cputime_ns / measurement_data['elapsed_ns'] * 100), - c_energy_impact, - coalition['diskio_bytesread'], - coalition['diskio_byteswritten'], - coalition['intr_wakeups'], - coalition['idle_wakeups'], - ) - - coaltion_db_id = DB().fetch_one(query=query, params=params)[0] - - for task in c_tasks: - t_energy_impact = round((task['energy_impact_per_s'] / 1_000_000_000) * measurement_data['elapsed_ns']) - t_cputime_ns = ((task['cputime_ms_per_s'] * 1_000_000) / 1_000_000_000) * measurement_data['elapsed_ns'] - - query = """ - INSERT INTO - hog_tasks ( - coalition, - name, - cputime_ns, - cputime_per, - energy_impact, - bytes_received, - bytes_sent, - diskio_bytesread, - diskio_byteswritten, - intr_wakeups, - idle_wakeups) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id - """ - params = ( - coaltion_db_id, - task['name'], - t_cputime_ns, - int(t_cputime_ns / measurement_data['elapsed_ns'] * 100), - t_energy_impact, - task.get('bytes_received', 0), - task.get('bytes_sent', 0), - task.get('diskio_bytesread', 0), - task.get('diskio_byteswritten', 0), - task.get('intr_wakeups', 0), - task.get('idle_wakeups', 0), - ) - DB().fetch_one(query=query, params=params) - - return Response(status_code=204) # No-Content - - -@app.get('/v1/hog/top_processes') -async def hog_get_top_processes(): - query = """ - SELECT - name, - (SUM(energy_impact)::bigint) AS total_energy_impact - FROM - hog_coalitions - GROUP BY - name - ORDER BY - total_energy_impact DESC - LIMIT 100; - """ - data = DB().fetch_all(query) - - if data is None: - data = [] - - query = """ - SELECT COUNT(DISTINCT machine_uuid) FROM hog_measurements; - """ - - machine_count = DB().fetch_one(query)[0] - - return ORJSONResponse({'success': True, 'process_data': data, 'machine_count': machine_count}) - - -@app.get('/v1/hog/machine_details/{machine_uuid}') -async def hog_get_machine_details(machine_uuid: str): - - if machine_uuid is None or not is_valid_uuid(machine_uuid): - return ORJSONResponse({'success': False, 'err': 'machine_uuid is empty or malformed'}, status_code=422) - - query = """ - SELECT - time, - combined_energy, - cpu_energy, - gpu_energy, - ane_energy, - energy_impact::bigint, - id - FROM - hog_measurements - WHERE - machine_uuid = %s - ORDER BY - time - """ - - data = DB().fetch_all(query, (machine_uuid,)) - - return ORJSONResponse({'success': True, 'data': data}) - - -@app.get('/v1/hog/coalitions_tasks/{machine_uuid}/{measurements_id_start}/{measurements_id_end}') -async def hog_get_coalitions_tasks(machine_uuid: str, measurements_id_start: int, measurements_id_end: int): - - if machine_uuid is None or not is_valid_uuid(machine_uuid): - return ORJSONResponse({'success': False, 'err': 'machine_uuid is empty'}, status_code=422) - - if measurements_id_start is None: - return ORJSONResponse({'success': False, 'err': 'measurements_id_start is empty'}, status_code=422) - - if measurements_id_end is None: - return ORJSONResponse({'success': False, 'err': 'measurements_id_end is empty'}, status_code=422) - - - coalitions_query = """ - SELECT - name, - (SUM(hc.energy_impact)::bigint) AS total_energy_impact, - (SUM(hc.diskio_bytesread)::bigint) AS total_diskio_bytesread, - (SUM(hc.diskio_byteswritten)::bigint) AS total_diskio_byteswritten, - (SUM(hc.intr_wakeups)::bigint) AS total_intr_wakeups, - (SUM(hc.idle_wakeups)::bigint) AS total_idle_wakeups, - (AVG(hc.cputime_per)::integer) AS avg_cpu_per - FROM - hog_coalitions AS hc - JOIN - hog_measurements AS hm ON hc.measurement = hm.id - WHERE - hc.measurement BETWEEN %s AND %s - AND hm.machine_uuid = %s - GROUP BY - name - ORDER BY - total_energy_impact DESC - LIMIT 100; - """ - - measurements_query = """ - SELECT - (SUM(combined_energy)::bigint) AS total_combined_energy, - (SUM(cpu_energy)::bigint) AS total_cpu_energy, - (SUM(gpu_energy)::bigint) AS total_gpu_energy, - (SUM(ane_energy)::bigint) AS total_ane_energy, - (SUM(energy_impact)::bigint) AS total_energy_impact - FROM - hog_measurements - WHERE - id BETWEEN %s AND %s - AND machine_uuid = %s - - """ - - coalitions_data = DB().fetch_all(coalitions_query, (measurements_id_start, measurements_id_end, machine_uuid)) - - energy_data = DB().fetch_one(measurements_query, (measurements_id_start, measurements_id_end, machine_uuid)) - - return ORJSONResponse({'success': True, 'data': coalitions_data, 'energy_data': energy_data}) - -@app.get('/v1/hog/tasks_details/{machine_uuid}/{measurements_id_start}/{measurements_id_end}/{coalition_name}') -async def hog_get_task_details(machine_uuid: str, measurements_id_start: int, measurements_id_end: int, coalition_name: str): - - if machine_uuid is None or not is_valid_uuid(machine_uuid): - return ORJSONResponse({'success': False, 'err': 'machine_uuid is empty'}, status_code=422) - - if measurements_id_start is None: - return ORJSONResponse({'success': False, 'err': 'measurements_id_start is empty'}, status_code=422) - - if measurements_id_end is None: - return ORJSONResponse({'success': False, 'err': 'measurements_id_end is empty'}, status_code=422) - - if coalition_name is None or not coalition_name.strip(): - return ORJSONResponse({'success': False, 'err': 'coalition_name is empty'}, status_code=422) - - tasks_query = """ - SELECT - t.name, - COUNT(t.id)::bigint AS number_of_tasks, - SUM(t.energy_impact)::bigint AS total_energy_impact, - SUM(t.cputime_ns)::bigint AS total_cputime_ns, - SUM(t.bytes_received)::bigint AS total_bytes_received, - SUM(t.bytes_sent)::bigint AS total_bytes_sent, - SUM(t.diskio_bytesread)::bigint AS total_diskio_bytesread, - SUM(t.diskio_byteswritten)::bigint AS total_diskio_byteswritten, - SUM(t.intr_wakeups)::bigint AS total_intr_wakeups, - SUM(t.idle_wakeups)::bigint AS total_idle_wakeups - FROM - hog_tasks t - JOIN hog_coalitions c ON t.coalition = c.id - JOIN hog_measurements m ON c.measurement = m.id - WHERE - c.name = %s - AND c.measurement BETWEEN %s AND %s - AND m.machine_uuid = %s - GROUP BY - t.name - ORDER BY - total_energy_impact DESC; - """ - - coalitions_query = """ - SELECT - c.name, - (SUM(c.energy_impact)::bigint) AS total_energy_impact, - (SUM(c.diskio_bytesread)::bigint) AS total_diskio_bytesread, - (SUM(c.diskio_byteswritten)::bigint) AS total_diskio_byteswritten, - (SUM(c.intr_wakeups)::bigint) AS total_intr_wakeups, - (SUM(c.idle_wakeups)::bigint) AS total_idle_wakeups - FROM - hog_coalitions c - JOIN hog_measurements m ON c.measurement = m.id - WHERE - c.name = %s - AND c.measurement BETWEEN %s AND %s - AND m.machine_uuid = %s - GROUP BY - c.name - ORDER BY - total_energy_impact DESC - LIMIT 100; - """ - - tasks_data = DB().fetch_all(tasks_query, (coalition_name, measurements_id_start,measurements_id_end, machine_uuid)) - coalitions_data = DB().fetch_one(coalitions_query, (coalition_name, measurements_id_start, measurements_id_end, machine_uuid)) - - return ORJSONResponse({'success': True, 'tasks_data': tasks_data, 'coalitions_data': coalitions_data}) - - - @app.post('/v1/software/add') async def software_add(software: Software, user: User = Depends(authenticate)): @@ -1123,414 +720,6 @@ async def robots_txt(): return Response(content=data, media_type='text/plain') - -@app.post('/v1/ci/measurement/add') -async def post_ci_measurement_add_deprecated( - request: Request, - measurement: CI_Measurement_Old, - user: User = Depends(authenticate) # pylint: disable=unused-argument - ): - - measurement = html_escape_multi(measurement) - - used_client_ip = get_connecting_ip(request) - - co2i_transformed = int(measurement.co2i) if measurement.co2i else None - - co2eq_transformed = int(float(measurement.co2eq)*1000000) if measurement.co2eq else None - - query = ''' - INSERT INTO - ci_measurements (energy_uj, - repo, - branch, - workflow_id, - run_id, - label, - source, - cpu, - commit_hash, - duration_us, - cpu_util_avg, - workflow_name, - lat, - lon, - city, - carbon_intensity_g, - carbon_ug, - filter_type, - filter_project, - filter_machine, - filter_tags, - user_id, - ip_address - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ''' - - params = ( measurement.energy_value*1000, measurement.repo, measurement.branch, - measurement.workflow, measurement.run_id, measurement.label, measurement.source, measurement.cpu, - measurement.commit_hash, measurement.duration*1000000, measurement.cpu_util_avg, measurement.workflow_name, - measurement.lat, measurement.lon, measurement.city, co2i_transformed, co2eq_transformed, - 'machine.ci', 'CI/CD', 'unknown', [], - user._id, used_client_ip) - - - DB().query(query=query, params=params) - - if measurement.energy_value <= 1 or (measurement.co2eq and co2eq_transformed <= 1): - error_helpers.log_error( - 'Extremely small energy budget was submitted to old Eco-CI API', - measurement=measurement - ) - - return Response(status_code=204) - - -@app.post('/v2/ci/measurement/add') -async def post_ci_measurement_add( - request: Request, - measurement: CI_Measurement, - user: User = Depends(authenticate) # pylint: disable=unused-argument - ): - - measurement = html_escape_multi(measurement) - - params = [measurement.energy_uj, measurement.repo, measurement.branch, - measurement.workflow, measurement.run_id, measurement.label, measurement.source, measurement.cpu, - measurement.commit_hash, measurement.duration_us, measurement.cpu_util_avg, measurement.workflow_name, - measurement.lat, measurement.lon, measurement.city, measurement.carbon_intensity_g, measurement.carbon_ug, - measurement.filter_type, measurement.filter_project, measurement.filter_machine] - - tags_replacer = ' ARRAY[]::text[] ' - if measurement.filter_tags: - tags_replacer = f" ARRAY[{','.join(['%s']*len(measurement.filter_tags))}] " - params = params + measurement.filter_tags - - used_client_ip = measurement.ip # If an ip has been given with the data. We prioritize that - if used_client_ip is None: - used_client_ip = get_connecting_ip(request) - - params.append(used_client_ip) - params.append(user._id) - - query = f""" - INSERT INTO - ci_measurements (energy_uj, - repo, - branch, - workflow_id, - run_id, - label, - source, - cpu, - commit_hash, - duration_us, - cpu_util_avg, - workflow_name, - lat, - lon, - city, - carbon_intensity_g, - carbon_ug, - filter_type, - filter_project, - filter_machine, - filter_tags, - ip_address, - user_id - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, - %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, - {tags_replacer}, - %s, %s) - - """ - - DB().query(query=query, params=params) - - if measurement.energy_uj <= 1 or (measurement.carbon_ug and measurement.carbon_ug <= 1): - error_helpers.log_error( - 'Extremely small energy budget was submitted to Eco-CI API', - measurement=measurement - ) - - return Response(status_code=204) - -@app.get('/v1/ci/measurements') -async def get_ci_measurements(repo: str, branch: str, workflow: str, start_date: date, end_date: date): - - query = """ - SELECT energy_uj, run_id, created_at, label, cpu, commit_hash, duration_us, source, cpu_util_avg, - (SELECT workflow_name FROM ci_measurements AS latest_workflow - WHERE latest_workflow.repo = ci_measurements.repo - AND latest_workflow.branch = ci_measurements.branch - AND latest_workflow.workflow_id = ci_measurements.workflow_id - ORDER BY latest_workflow.created_at DESC - LIMIT 1) AS workflow_name, - lat, lon, city, carbon_intensity_g, carbon_ug - FROM ci_measurements - WHERE - repo = %s AND branch = %s AND workflow_id = %s - AND DATE(created_at) >= TO_DATE(%s, 'YYYY-MM-DD') - AND DATE(created_at) <= TO_DATE(%s, 'YYYY-MM-DD') - ORDER BY run_id ASC, created_at ASC - """ - params = (repo, branch, workflow, str(start_date), str(end_date)) - data = DB().fetch_all(query, params=params) - - if data is None or data == []: - return Response(status_code=204) # No-Content - - return ORJSONResponse({'success': True, 'data': data}) - -@app.get('/v1/ci/repositories') -async def get_ci_repositories(repo: str | None = None, sort_by: str = 'name'): - - params = [] - query = """ - SELECT repo, source, MAX(created_at) as last_run - FROM ci_measurements - WHERE 1=1 - """ - - if repo: # filter is currently not used, but may be a feature in the future - query = f"{query} AND ci_measurements.repo = %s \n" - params.append(repo) - - query = f"{query} GROUP BY repo, source" - - if sort_by == 'date': - query = f"{query} ORDER BY last_run DESC" - else: - query = f"{query} ORDER BY repo ASC" - - data = DB().fetch_all(query, params=tuple(params)) - if data is None or data == []: - return Response(status_code=204) # No-Content - - return ORJSONResponse({'success': True, 'data': data}) # no escaping needed, as it happend on ingest - - -@app.get('/v1/ci/runs') -async def get_ci_runs(repo: str, sort_by: str = 'name'): - - params = [] - query = """ - SELECT repo, branch, workflow_id, source, MAX(created_at) as last_run, - (SELECT workflow_name FROM ci_measurements AS latest_workflow - WHERE latest_workflow.repo = ci_measurements.repo - AND latest_workflow.branch = ci_measurements.branch - AND latest_workflow.workflow_id = ci_measurements.workflow_id - ORDER BY latest_workflow.created_at DESC - LIMIT 1) AS workflow_name - FROM ci_measurements - WHERE 1=1 - """ - - query = f"{query} AND ci_measurements.repo = %s \n" - params.append(repo) - query = f"{query} GROUP BY repo, branch, workflow_id, source" - - if sort_by == 'date': - query = f"{query} ORDER BY last_run DESC" - else: - query = f"{query} ORDER BY repo ASC" - - data = DB().fetch_all(query, params=tuple(params)) - if data is None or data == []: - return Response(status_code=204) # No-Content - - return ORJSONResponse({'success': True, 'data': data}) # no escaping needed, as it happend on ingest - -@app.get('/v1/ci/badge/get') -async def get_ci_badge_get(repo: str, branch: str, workflow:str): - query = """ - SELECT SUM(energy_uj), MAX(run_id) - FROM ci_measurements - WHERE repo = %s AND branch = %s AND workflow_id = %s - GROUP BY run_id - ORDER BY MAX(created_at) DESC - LIMIT 1 - """ - - params = (repo, branch, workflow) - data = DB().fetch_one(query, params=params) - - if data is None or data == [] or data[1] is None: # special check for data[1] as this is aggregate query which always returns result - return Response(status_code=204) # No-Content - - energy_value = data[0] - - [energy_value, energy_unit] = rescale_energy_value(energy_value, 'uJ') - badge_value= f"{energy_value:.2f} {energy_unit}" - - badge = anybadge.Badge( - label='Energy Used', - value=xml_escape(badge_value), - num_value_padding_chars=1, - default_color='green') - return Response(content=str(badge), media_type="image/svg+xml") - - -@app.post('/v1/carbondb/add') -async def add_carbondb_deprecated(): - return Response("This endpoint is not supported anymore. Please migrate to /v2/carbondb/add !", status_code=410) - -@app.post('/v2/carbondb/add') -async def add_carbondb( - request: Request, - energydata: EnergyData, - user: User = Depends(authenticate) # pylint: disable=unused-argument - ): - - try: - carbondb_add(get_connecting_ip(request), energydata.dict(), 'CUSTOM', user._id) - except ValueError as exc: - raise RequestValidationError(str(exc)) from exc - - return Response(status_code=204) - - -@app.get('/v1/carbondb/') -async def get_carbondb_deprecated(): - return Response("This endpoint is not supported anymore. Please migrate to /v2/carbondb/ !", status_code=410) - -@app.get('/v2/carbondb') -async def carbondb_get( - user: User = Depends(authenticate), - start_date: date | None = None, end_date: date | None = None, - tags_include: str | None = None, tags_exclude: str | None = None, - types_include: str | None = None, types_exclude: str | None = None, - projects_include: str | None = None, projects_exclude: str | None = None, - machines_include: str | None = None, machines_exclude: str | None = None, - sources_include: str | None = None, sources_exclude: str | None = None - ): - - params = [user._id,] - - start_date_condition = '' - if start_date is not None: - start_date_condition = "AND DATE(cedd.date) >= %s" - params.append(start_date) - - end_date_condition = '' - if end_date is not None: - end_date_condition = "AND DATE(cedd.date) <= %s" - params.append(end_date) - - tags_include_condition = '' - if tags_include: - tags_include_list = tags_include.split(',') - tags_include_condition = f" AND cedd.tags @> ARRAY[{','.join(['%s::integer']*len(tags_include_list))}]" - params = params + tags_include_list - - tags_exclude_condition = '' - if tags_exclude: - tags_exclude_list = tags_exclude.split(',') - tags_exclude_condition = f" AND cedd.tags NOT @> ARRAY[{','.join(['%s::integer']*len(tags_exclude_list))}]" - params = params + tags_exclude_list - - machines_include_condition = '' - if machines_include: - machines_include_list = machines_include.split(',') - machines_include_condition = f" AND cedd.machine IN ({','.join(['%s']*len(machines_include_list))})" - params = params + machines_include_list - - machines_exclude_condition = '' - if machines_exclude: - machines_exclude_list = machines_exclude.split(',') - machines_exclude_condition = f" AND cedd.machine NOT IN ({','.join(['%s']*len(machines_exclude_list))})" - params = params + machines_exclude_list - - types_include_condition = '' - if types_include: - types_include_list = types_include.split(',') - types_include_condition = f" AND cedd.type IN ({','.join(['%s']*len(types_include_list))})" - params = params + types_include_list - - types_exclude_condition = '' - if types_exclude: - types_exclude_list = types_exclude.split(',') - types_exclude_condition = f" AND cedd.type NOT IN ({','.join(['%s']*len(types_exclude_list))})" - params = params + types_exclude_list - - projects_include_condition = '' - if projects_include: - projects_include_list = projects_include.split(',') - projects_include_condition = f" AND cedd.project IN ({','.join(['%s']*len(projects_include_list))})" - params = params + projects_include_list - - projects_exclude_condition = '' - if projects_exclude: - projects_exclude_list = projects_exclude.split(',') - projects_exclude_condition = f" AND cedd.project NOT IN ({','.join(['%s']*len(projects_exclude_list))})" - params = params + projects_exclude_list - - sources_include_condition = '' - if sources_include: - sources_include_list = sources_include.split(',') - sources_include_condition = f" AND cedd.source IN ({','.join(['%s']*len(sources_include_list))})" - params = params + sources_include_list - - sources_exclude_condition = '' - if sources_exclude: - sources_exclude_list = sources_exclude.split(',') - sources_exclude_condition = f" AND cedd.source NOT IN ({','.join(['%s']*len(sources_exclude_list))})" - params = params + sources_exclude_list - - query = f""" - SELECT - type, project, machine, source, tags, date, energy_kwh_sum, carbon_kg_sum, carbon_intensity_g_avg, record_count - FROM - carbondb_data as cedd - WHERE - user_id = %s - {start_date_condition} - {end_date_condition} - {tags_include_condition} - {tags_exclude_condition} - {machines_include_condition} - {machines_exclude_condition} - {types_include_condition} - {types_exclude_condition} - {projects_include_condition} - {projects_exclude_condition} - {sources_include_condition} - {sources_exclude_condition} - - ORDER BY - date ASC - ; - """ - data = DB().fetch_all(query, params) - - return ORJSONResponse({'success': True, 'data': data}) - - -@app.get('/v2/carbondb/filters') -async def carbondb_get_filters( - user: User = Depends(authenticate) - ): - - query = 'SELECT jsonb_object_agg(id, type) FROM carbondb_types WHERE user_id = %s' - carbondb_types = DB().fetch_one(query, (user._id, ))[0] - - query = 'SELECT jsonb_object_agg(id, tag) FROM carbondb_tags WHERE user_id = %s' - carbondb_tags = DB().fetch_one(query, (user._id, ))[0] - - query = 'SELECT jsonb_object_agg(id, machine) FROM carbondb_machines WHERE user_id = %s' - carbondb_machines = DB().fetch_one(query, (user._id, ))[0] - - query = 'SELECT jsonb_object_agg(id, project) FROM carbondb_projects WHERE user_id = %s' - carbondb_projects = DB().fetch_one(query, (user._id, ))[0] - - query = 'SELECT jsonb_object_agg(id, source) FROM carbondb_sources WHERE user_id = %s' - carbondb_sources = DB().fetch_one(query, (user._id, ))[0] - - return ORJSONResponse({'success': True, 'data': {'types': carbondb_types, 'tags': carbondb_tags, 'machines': carbondb_machines, 'projects': carbondb_projects, 'sources': carbondb_sources}}) - - # @app.get('/v1/authentication/new') # This will fail if the DB insert fails but still report 'success': True # Must be reworked if we want to allow API based token generation @@ -1543,5 +732,13 @@ async def carbondb_get_filters( async def read_authentication_token(user: User = Depends(authenticate)): return ORJSONResponse({'success': True, 'data': user.to_dict()}) +app.include_router(eco_ci.router) + +# include enterprise functionality if activated +if GlobalConfig().config.get('ee_token', False): + from ee.api import carbondb, power_hog + app.include_router(carbondb.router) + app.include_router(power_hog.router) + if __name__ == '__main__': app.run() # pylint: disable=no-member diff --git a/api/object_specifications.py b/api/object_specifications.py index efea4aad6..41698473a 100644 --- a/api/object_specifications.py +++ b/api/object_specifications.py @@ -1,92 +1,23 @@ -from typing import List, Dict, Optional -from pydantic import BaseModel, ConfigDict, field_validator, Field -from fastapi.exceptions import RequestValidationError - - -###### HOG - -class HogMeasurement(BaseModel): - time: int - data: str - settings: str - machine_uuid: str - row_id: Optional[int] = -1 # we use this only for debugging - - model_config = ConfigDict(extra='forbid') - - -class Task(BaseModel): - # We need to set the optional to a value as otherwise the key is required in the input - # https://docs.pydantic.dev/latest/migration/#required-optional-and-nullable-fields - name: str - cputime_ns: int - timer_wakeups: List - diskio_bytesread: Optional[int] = 0 - diskio_byteswritten: Optional[int] = 0 - packets_received: int - packets_sent: int - bytes_received: int - bytes_sent: int - energy_impact: float +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Optional - model_config = ConfigDict(extra='forbid') +from fastapi.exceptions import RequestValidationError +### Software Add -class Coalition(BaseModel): +class Software(BaseModel): name: str - cputime_ns: int - diskio_bytesread: int = 0 - diskio_byteswritten: int = 0 - energy_impact: float - tasks: List[Task] - - model_config = ConfigDict(extra='forbid') - -class Processor(BaseModel): - # https://docs.pydantic.dev/latest/migration/#required-optional-and-nullable-fields - clusters: Optional[List] = None - cpu_power_zones_engaged: Optional[float] = None - cpu_energy: Optional[int] = None - cpu_power: Optional[float] = None - gpu_energy: Optional[int] = None - gpu_power: Optional[float] = None - ane_energy: Optional[int] = None - ane_power: Optional[float] = None - combined_power: Optional[float] = None - package_joules: Optional[float] = None - cpu_joules: Optional[float] = None - igpu_watts: Optional[float] = None - - model_config = ConfigDict(extra='forbid') - -class GPU(BaseModel): - gpu_energy: Optional[int] = None - - model_config = ConfigDict(extra='forbid') - - -class Measurement(BaseModel): - is_delta: bool - elapsed_ns: int - timestamp: int - coalitions: List[Coalition] - all_tasks: Dict - network: Optional[Dict] = None # network is optional when system is in flight mode / network turned off - disk: Optional[Dict] = None # No idea what system would not have a disk but we are seeing this in production - interrupts: List - processor: Processor - thermal_pressure: str - sfi: Dict - gpu: Optional[GPU] = None + url: str + email: str + filename: str + branch: str + machine_id: int + schedule_mode: str model_config = ConfigDict(extra='forbid') ### Eco-CI - - - - # pylint: disable=invalid-name class CI_Measurement_Old(BaseModel): energy_value: int @@ -172,48 +103,3 @@ def check_empty_elements(cls, value): if any(not item or item.strip() == '' for item in value): raise ValueError("The list contains empty elements.") return value - - - -### Software Add - -class Software(BaseModel): - name: str - url: str - email: str - filename: str - branch: str - machine_id: int - schedule_mode: str - - model_config = ConfigDict(extra='forbid') - - -### CarbonDB - -class EnergyData(BaseModel): - tags: Optional[list] = Field(default_factory=list) # never do a reference object as default as it will be shared - project: str - machine: str - type: str - time: int # value is in us as UTC timestamp - energy_uj: int # is in uJ - carbon_intensity_g: Optional[int] = None # Will be populated if not transmitted, so we never have NULL in DB - ip: Optional[str] = None # Will be populated if not transmitted, so we never have NULL in DB - - model_config = ConfigDict(extra='forbid') - - - @field_validator('ip', 'project', 'machine','type') - @classmethod - def empty_str_to_none(cls, values, _): - if not values or values.strip() == '': - raise ValueError('Value is empty') - return values - - @field_validator('tags') - @classmethod - def check_empty_elements(cls, value): - if any(not item or item.strip() == '' for item in value): - raise ValueError("The list contains empty elements.") - return value diff --git a/config.yml.example b/config.yml.example index f7b99e1e8..02e8c3d2f 100644 --- a/config.yml.example +++ b/config.yml.example @@ -222,3 +222,7 @@ sci: # You can get this under https://api-portal.electricitymaps.com/ # This is a free service please note that you need to pay if you want to use this commercially! #electricity_maps_token: '123' + +# GMT can activate additional enterprise only functionality +# If you have a subscription insert your token here +ee_token: False \ No newline at end of file diff --git a/cron/carbondb_compress.py b/cron/carbondb_compress.py deleted file mode 100644 index bb5ae9034..000000000 --- a/cron/carbondb_compress.py +++ /dev/null @@ -1,135 +0,0 @@ -import sys -import faulthandler -faulthandler.enable(file=sys.__stderr__) # will catch segfaults and write to stderr - -import os - -from lib.global_config import GlobalConfig -from lib.db import DB -from lib import error_helpers - -# The main job of the compress script is to take all the data from the carbondb_data_raw table -# and compress it to daily sums. -# During this process we also transform all text fields and transform them to integers and drop them into normalized -# joined tables. - -########### Remove NULL values from tags -# UPDATE carbondb_data_raw -# SET tags = array_remove(tags, NULL) -# WHERE array_position(tags, NULL) IS NOT NULL; - - -def compress_carbondb_raw(): - query = ''' - - INSERT INTO carbondb_types (type, user_id) - SELECT DISTINCT type, user_id - FROM carbondb_data_raw - ON CONFLICT (type, user_id) DO NOTHING; - - INSERT INTO carbondb_machines (machine, user_id) - SELECT DISTINCT machine, user_id - FROM carbondb_data_raw - ON CONFLICT (machine, user_id) DO NOTHING; - - INSERT INTO carbondb_tags (tag, user_id) - SELECT DISTINCT unnest(tags), user_id - FROM carbondb_data_raw - ON CONFLICT (tag, user_id) DO NOTHING; - - INSERT INTO carbondb_sources (source, user_id) - SELECT DISTINCT source, user_id - FROM carbondb_data_raw - ON CONFLICT (source, user_id) DO NOTHING; - - INSERT INTO carbondb_projects (project, user_id) - SELECT DISTINCT project, user_id - FROM carbondb_data_raw - ON CONFLICT (project, user_id) DO NOTHING; - - DROP TABLE IF EXISTS carbondb_data_raw_tmp; - - CREATE TEMPORARY TABLE carbondb_data_raw_tmp AS - SELECT * FROM carbondb_data_raw; - - UPDATE carbondb_data_raw_tmp AS cdrt - SET "type" = s.id - FROM carbondb_types AS s - WHERE cdrt.type = s.type AND cdrt.user_id = s.user_id; - - UPDATE carbondb_data_raw_tmp AS cdrt - SET "source" = s.id - FROM carbondb_sources AS s - WHERE cdrt.source = s.source AND cdrt.user_id = s.user_id; - - UPDATE carbondb_data_raw_tmp AS cdrt - SET "machine" = s.id - FROM carbondb_machines AS s - WHERE cdrt.machine = s.machine AND cdrt.user_id = s.user_id; - - UPDATE carbondb_data_raw_tmp AS cdrt - SET "project" = s.id - FROM carbondb_projects AS s - WHERE cdrt.project = s.project AND cdrt.user_id = s.user_id; - - UPDATE carbondb_data_raw_tmp - SET tags = COALESCE( - (SELECT ARRAY_AGG(t2.id) - FROM UNNEST(carbondb_data_raw_tmp.tags) AS elem - LEFT JOIN carbondb_tags AS t2 ON t2.tag = elem and t2.user_id = carbondb_data_raw_tmp.user_id) - , ARRAY[]::int[] - ); - - INSERT INTO carbondb_data ( - type, - machine, - project, - source, - tags, - date, - energy_kwh_sum, - carbon_kg_sum, - carbon_intensity_g_avg, - record_count, - user_id - ) - SELECT - cdr.type::int, - cdr.machine::int, - cdr.project::int, - cdr.source::int, - cdr.tags::int[], - DATE_TRUNC('day', TO_TIMESTAMP(cdr.time / 1000000)), - SUM(cdr.energy_kwh), - SUM(cdr.carbon_kg), - COALESCE(SUM(cdr.carbon_kg)*1e3 / NULLIF(SUM(cdr.energy_kwh), 0), 0), -- weighted average instead of just averaging carbon_intensity. Since the solar panel might not be producing power at all for a day, which results in 0, we need to COALESCE and insert 0 in this case - COUNT(*), - cdr.user_id - FROM - carbondb_data_raw_tmp AS cdr - GROUP BY - cdr.type, - cdr.source, - cdr.machine, - cdr.project, - cdr.tags, - DATE_TRUNC('day', TO_TIMESTAMP(cdr.time / 1000000)), - cdr.user_id - ON CONFLICT (type, source, machine, project, tags, date, user_id) DO UPDATE - SET - -- excluded will take the fields positional from the insert query. The names are not the actual column values - -- but the calculation planned for these columns through the SELECT statement - energy_kwh_sum = EXCLUDED.energy_kwh_sum, - carbon_kg_sum = EXCLUDED.carbon_kg_sum, - carbon_intensity_g_avg = EXCLUDED.carbon_intensity_g_avg, - record_count = EXCLUDED.record_count; - ''' - DB().query(query) - - -if __name__ == '__main__': - try: - GlobalConfig().override_config(config_location=f"{os.path.dirname(os.path.realpath(__file__))}/../manager-config.yml") - compress_carbondb_raw() - except Exception as exc: # pylint: disable=broad-except - error_helpers.log_error(f'Processing in {__file__} failed.', exception=exc, machine=GlobalConfig().config['machine']['description']) diff --git a/cron/carbondb_compress.py b/cron/carbondb_compress.py new file mode 120000 index 000000000..f2f07f690 --- /dev/null +++ b/cron/carbondb_compress.py @@ -0,0 +1 @@ +../ee/cron/carbondb_compress.py \ No newline at end of file diff --git a/cron/carbondb_copy_over_and_remove_duplicates.py b/cron/carbondb_copy_over_and_remove_duplicates.py deleted file mode 100644 index b05f7a078..000000000 --- a/cron/carbondb_copy_over_and_remove_duplicates.py +++ /dev/null @@ -1,113 +0,0 @@ -import faulthandler -faulthandler.enable() # will catch segfaults and write to stderr - -import os - -from lib.global_config import GlobalConfig -from lib.db import DB -from lib import error_helpers - -def copy_over_eco_ci(): - DB().query(''' - INSERT INTO carbondb_data_raw - ("type", "project", "machine", "source", "tags","time","energy_kwh","carbon_kg","carbon_intensity_g","latitude","longitude","ip_address","user_id","created_at") - - SELECT - filter_type, - filter_project, - filter_machine, - 'Eco-CI', - filter_tags, - EXTRACT(EPOCH FROM created_at) * 1e6, - (energy_uj::DOUBLE PRECISION)/1e6/3600/1000, -- to get to kWh - (carbon_ug::DOUBLE PRECISION)/1e9, -- to get to kg - 0, -- (carbon_intensity_g) there is no need for this column for further processing - 0.0, -- (latitude) there is no need for this column for further processing - 0.0, -- (longitude) there is no need for this column for further processing - ip_address, - user_id, - created_at - FROM ci_measurements - WHERE - created_at >= CURRENT_DATE - INTERVAL '1 DAYS'; - ''') - -def copy_over_gmt(): - DB().query(''' - - INSERT INTO carbondb_data_raw - ("type", "project", "machine", "source", "tags","time","energy_kwh","carbon_kg","carbon_intensity_g","latitude","longitude","ip_address","user_id","created_at") - SELECT - 'machine.server' as type, - 'Energy-ID' as project, - m.description, - 'Green Metrics Tool', - ARRAY[]::text[] as tags , - EXTRACT(EPOCH FROM r.created_at) * 1e6 as time, - - -- we do these two queries as subselects as if they were left joins they will blow up the table whenever we relax the condition that only one metric with same name may exist - (SELECT SUM(value::DOUBLE PRECISION) FROM phase_stats as p WHERE p.run_id = r.id AND p.unit = 'mJ' AND p.metric LIKE '%_energy_%_machine')/1e3/3600/1000 as energy_kwh, - (SELECT SUM(value::DOUBLE PRECISION) FROM phase_stats as p2 WHERE p2.run_id = r.id AND p2.unit = 'ug' AND p2.metric LIKE '%_carbon_%')/1e9 as carbon_kg, - - 0, -- there is no need for this column for further processing - 0.0, -- there is no need for this column for further processing - 0.0, -- there is no need for this column for further processing - NULL, - r.user_id, - r.created_at - FROM runs as r - -- we do LEFT JOIN as we do not want to silent skip data. If a column gets NULL it will fail - LEFT JOIN machines as m ON m.id = r.machine_id - - WHERE - r.user_id IS NOT NULL - AND r.created_at >= CURRENT_DATE - INTERVAL '30 DAYS' - GROUP BY - r.id, m.description; - - ''') - -def validate_table_constraints(): - data = DB().fetch_all(''' - SELECT - column_name, - is_nullable - FROM - information_schema.columns - WHERE - table_name = 'carbondb_data_raw' - AND column_name IN ('user_id', 'time', 'energy_kwh', 'carbon_kg', 'carbon_intensity_g', 'type', 'project', 'machine', 'source', 'tags') - ''') - - for row in data: - if row[1] == 'YES': - raise RuntimeError(f"{row[0]} was NULL-able: {row[1]}. CarbonDB cannot remove duplicates.") - - -def remove_duplicates(): - validate_table_constraints() # since the query works only if columns are not null - DB().query(''' - DELETE FROM carbondb_data_raw a - USING carbondb_data_raw b - WHERE - a.ctid < b.ctid - AND a.time = b.time - AND a.machine = b.machine - AND a.type = b.type - AND a.project = b.project - AND a.source = b.source - AND a.tags = b.tags - AND a.energy_kwh = b.energy_kwh - AND a.carbon_kg = b.carbon_kg - AND a.user_id = b.user_id; - ''') - - -if __name__ == '__main__': - try: - GlobalConfig().override_config(config_location=f"{os.path.dirname(os.path.realpath(__file__))}/../manager-config.yml") - copy_over_eco_ci() - copy_over_gmt() - remove_duplicates() - except Exception as exc: # pylint: disable=broad-except - error_helpers.log_error(f'Processing in {__file__} failed.', exception=exc, machine=GlobalConfig().config['machine']['description']) diff --git a/cron/carbondb_copy_over_and_remove_duplicates.py b/cron/carbondb_copy_over_and_remove_duplicates.py new file mode 120000 index 000000000..8aea64a20 --- /dev/null +++ b/cron/carbondb_copy_over_and_remove_duplicates.py @@ -0,0 +1 @@ +../ee/cron/carbondb_copy_over_and_remove_duplicates.py \ No newline at end of file diff --git a/docker/requirements.txt b/docker/requirements.txt index 5e0f0bd18..2cf6a0fab 100644 --- a/docker/requirements.txt +++ b/docker/requirements.txt @@ -15,3 +15,5 @@ redis==5.2.0 hiredis==3.0.0 requests==2.32.3 uvicorn-worker==0.2.0 + +pytest==8.3.3 # needed because we need to exit in db.py if tests run with wrong config diff --git a/ee b/ee new file mode 160000 index 000000000..2a6febf2b --- /dev/null +++ b/ee @@ -0,0 +1 @@ +Subproject commit 2a6febf2b9ef5d134c11b71c070a92a1b1f7672d diff --git a/frontend/carbondb-details.html b/frontend/carbondb-details.html deleted file mode 100644 index df387732b..000000000 --- a/frontend/carbondb-details.html +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - Green Metrics Tool - - - - - - - - - - - - - - - -
-

- - Green Metrics Tool - CarbonDB -

-
-
-
Your machine stats
-
-
-
-
-
-
-
- Sum Energy (J) -
-
-
-
-
-
-
- Sum CO2eq (g) -
-
-
-
-
-
-
- Avg. Carbon Intensity (gCO2e/kWh) -
-
-
-
-
-
-
- Records received -
-
-
-
-

 

-
Your machine details
-
-
-
-
-
-
-
-
- Why can I only see days? -
-

- This is the free version of the Green Metrics Tool. Because saving all the values would be quite expensive we - only offer daily statistic for free. If you want detailed data please check out our paid plans. - You can see how this would look like on our demo machine. -

-
-
-
- Overall energy statistics -
-

-
-
-
- - \ No newline at end of file diff --git a/frontend/carbondb-lists.html b/frontend/carbondb-lists.html deleted file mode 100644 index 45418c557..000000000 --- a/frontend/carbondb-lists.html +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - Green Metrics Tool - - - - - - - - - - - - - - - -
-

- - Green Metrics Tool - CarbonDB -

-
-
-
Your stats
-
-
-
-
-
-
-
- Sum Energy (J) -
-
-
-
-
-
-
- Sum CO2eq (g) -
-
-
-
-
-
-
-
- Overall energy statistics -
-

-
-
- Filtered by: -
- - -
-
-
-
- - \ No newline at end of file diff --git a/frontend/carbondb.html b/frontend/carbondb.html deleted file mode 100644 index f0e28bea5..000000000 --- a/frontend/carbondb.html +++ /dev/null @@ -1,285 +0,0 @@ - - - - - - - - - - - - - Green Metrics Tool - - - - - - - - - - - - - - - - - - - - - -
-

- - Green Metrics Tool - CarbonDB -

-
-
-
What is CarbonDB? -
-
- -

The idea behind CarbonDB is that a lot of components in your stack produce different amounts of CO2 based on how much energy they consume, how long the hardware is used, what time and where certain operations are computed and many more. For companies it is vital to record all this data so they can a) improve their stack and also b) report the data as will be required by new EU laws.

-

The solution is a central database in which all the different services can report their usage and then there is a central point where analytics and further operations can be performed

-

You can read all about it under: https://www.green-coding.io/projects/carbondb/

-
-
-
-
- -
-
- Why am I not seeing any data? -
-

Maybe your user ID is not correctly set in the Dashboard? Go to Authentication and enter your token.

-

If you are using the hosted version of GMT please note that CarbonDB is a premium feature and you need to get a token first.

-
-
-
-
- Show Filters - -
- -
- -
-

Filters

-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- - -
-
-
-
-
- -
-
-
-
-
- -- -
-
- Total Carbon [kg] -
-
-
-
-
-
-
-
- -- -
-
- Total Energy [kWh] -
-
-
-
-
-
-
-
- -- -
-
- Total Machines -
-
-
-
-
-
-
-
- -- -
-
- Carbon per machine [kg/Unit] -
-
-
-
-
-
-
-
- -- -
-
- AVG carbon intensity [g] -
-
-
-
- -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- - \ No newline at end of file diff --git a/frontend/carbondb.html b/frontend/carbondb.html new file mode 120000 index 000000000..10fdaf52d --- /dev/null +++ b/frontend/carbondb.html @@ -0,0 +1 @@ +../ee/frontend/carbondb.html \ No newline at end of file diff --git a/frontend/hog-details.html b/frontend/hog-details.html deleted file mode 100644 index ea416754c..000000000 --- a/frontend/hog-details.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - - - - - - - - Green Metrics Tool - - - - - - - - - - - - - - - - - - - - -
-

- - Green Metrics Tool - Power HOG -

-
-
-
Your machine stats
-
-

You can click on a bar that you want more details about. Please note that we don't show this one bar - as the surrounding processes might also influence the results. Please use the zoom to look at one specific bar. -

-

-
-
-
-
- -
Loading and processing data
- -
-
- Overall energy statistics -
-

-
-
- - -
-
- Per process energy statistics -
-

- - - - - - \ No newline at end of file diff --git a/frontend/hog-details.html b/frontend/hog-details.html new file mode 120000 index 000000000..8a42d63a5 --- /dev/null +++ b/frontend/hog-details.html @@ -0,0 +1 @@ +../ee/frontend/hog-details.html \ No newline at end of file diff --git a/frontend/hog.html b/frontend/hog.html deleted file mode 100644 index 7f41baa60..000000000 --- a/frontend/hog.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - Green Metrics Tool - - - - - - - - - - - - - - - - - -
-

- - Green Metrics Tool - Power HOG -

-
-
-
Status overview
-
-

This list shows you all processes we have monitored on the machines that run the power hog.

-

If you are looking for the data from your install please click on "Details" in the power hog desktop app.

-

Number of Machines this data is based on: 0

-

-
-
-
-

Processes

-
-
- - \ No newline at end of file diff --git a/frontend/hog.html b/frontend/hog.html new file mode 120000 index 000000000..8993891df --- /dev/null +++ b/frontend/hog.html @@ -0,0 +1 @@ +../ee/frontend/hog.html \ No newline at end of file diff --git a/frontend/js/carbondb-details.js b/frontend/js/carbondb-details.js deleted file mode 100644 index 1b938ceef..000000000 --- a/frontend/js/carbondb-details.js +++ /dev/null @@ -1,129 +0,0 @@ -$(document).ready(function () { - function getURLParameter(name) { - return new URLSearchParams(window.location.search).get(name); - } - - (async () => { - const machine_uuid = getURLParameter('machine_uuid') - try { - - var measurements = await makeAPICall(`/v1/carbondb/machine/day/${machine_uuid}`); - } catch (err) { - showNotification('Could not get data from API', err); - return; - } - if (measurements.data.length == 0){ - showNotification('No data', 'We could not find any data. Did you follow a correct URL?') - return - } - - let types = new Set(); - let companies = new Set(); - let machines = new Set(); - let projects = new Set(); - let tags = new Set(); - - measurements.data.forEach(item => { - types.add(item[1]); - companies.add(item[2]); - machines.add(item[3]); - projects.add(item[4]); - item[5]?.forEach(tag => tags.add(tag)); - }); - - types = Array.from(types); - companies = Array.from(companies); - machines = Array.from(machines); - projects = Array.from(projects); - tags = Array.from(tags); - - let info_string = `` - - if (types.length > 0){ - info_string += ` -
-
- Type: ${types.map(c => `${c} `).join('')} -
-
- ` - } - if (companies.length > 0){ - info_string += ` -
-
- Company: ${companies.map(c => `${c}
`).join('')} -
-
- ` - } - if (projects.length > 0){ - info_string += ` -
-
- Project: ${projects.map(c => `${c}
`).join('')} -
-
- ` - } - if (tags.length > 0){ - info_string += ` -
-
- Tags: ${tags.map(c => `
${c}
`).join('')} -
-
- ` - } - - $('#detail_list').append(info_string); - - const table_td_string = measurements.data.map(subArr => ` - - - ${subArr[6]} - ${subArr[7].toFixed(4)} - ${subArr[8].toFixed(4)} - ${subArr[9].toFixed(2)} - ${subArr[10]} - - `).join(' '); - - $("#energy_table").html(` - - - - - - - - - - - - ${table_td_string} - -
DateEnergy (J)CO2eq (g)Intensity (gCO2e/kWh)Records
- `) - - let sumEnergy = 0; - let sumCO2 = 0; - let sumCount = 0; - let sumCarbonIntensity = 0; - - measurements.data.forEach(item => { - sumEnergy += item[7]; - sumCO2 += item[8]; - sumCount += item[10]; - sumCarbonIntensity += item[9]; - }); - - const averageCarbonIntensity = sumCarbonIntensity / measurements.data.length; - - $("#sum_energy").html(sumEnergy.toFixed(2)); - $("#sum_co2eq").html(sumCO2.toFixed(6)); - $("#sum_records").html(sumCount); - $("#avg_carbon_intensity").html(averageCarbonIntensity.toFixed(0)); - - })(); -}); diff --git a/frontend/js/carbondb-lists.js b/frontend/js/carbondb-lists.js deleted file mode 100644 index f6757f985..000000000 --- a/frontend/js/carbondb-lists.js +++ /dev/null @@ -1,118 +0,0 @@ -$(document).ready(function () { - - function getQueryParameters(name) { - const urlParams = new URLSearchParams(window.location.search); - const allParams = urlParams.getAll(name); - return [...new Set(allParams)]; - } - - function filterDataByTags(m) { - const tags = getQueryParameters('tag'); - - if (tags.length > 0) { - return m.data.filter(item => { - return tags.every(tag => item[4].includes(tag)); - }); - } - - return m.data; - } - - (async () => { - $('#filter_tags_container').hide(); - - const company_uuid = getQueryParameters('company_uuid')[0]; - const project_uuid = getQueryParameters('project_uuid')[0]; - - if(company_uuid){ - var query_string = 'company'; - var query_param = company_uuid; - }else if(project_uuid){ - var query_string = 'project'; - var query_param = project_uuid; - }else{ - showNotification('No company or project supplied as parameter. Dowing nothing!'); - return; - } - - try { - var measurements = await makeAPICall(`/v1/carbondb/${query_string}/${query_param}`); - } catch (err) { - showNotification('Could not get data from API', err); - return; - } - - if (measurements.data.length == 0){ - showNotification('No data', 'We could not find any data. Did you follow a correct URL?') - return - } - - measurements = filterDataByTags(measurements); - - const ftags = getQueryParameters('tag'); - if (ftags.length > 0) { - $('#filter_tags_container').show(); - const tagsFilterHtml = ftags.map(tag => `${escapeString(tag)}`).join(' '); - $('#filter_tags').append(tagsFilterHtml); - $('#js_remove_filters').click(function(){ - const url = new URL(window.location.href); - const newParams = new URLSearchParams(); - url.searchParams.forEach((value, key) => { - if (key !== 'tag') { - newParams.append(key, value); - } - }); - window.location.href = `${url.origin}${url.pathname}?${newParams.toString()}`; - }) - } - - const table_td_string = measurements.map(subArr => { - const tagsHtml = subArr[4] - .filter(tag => tag !== null) - .map(tag => `${escapeString(tag)}`) - .join(' '); - - return ` - - ${subArr[0]} - ${subArr[1].toFixed(2)} - ${subArr[2].toFixed(2)} - ${subArr[3].toFixed(2)} - ${tagsHtml} - - `; - }).join(' '); - - $("#energy_table").html(` - - - - - - - - - - - ${table_td_string} - -
MachineSum Energy (J)Sum CO2eq (g)Avg. Intensity (gCO2e/kWh)Tags (click to filter) -
- `) - - let sumEnergy = 0; - let sumCO2 = 0; - - // In this case we can't calculate the carbon intensity as this would be averages from averages - //let sumCarbonIntensity = 0; - - measurements.forEach(item => { - sumEnergy += item[1]; - sumCO2 += item[2]; - }); - - $("#sum_energy").html(sumEnergy.toFixed(2)); - $("#sum_co2eq").html(sumCO2.toFixed(2)); - - })(); -}); diff --git a/frontend/js/carbondb.js b/frontend/js/carbondb.js deleted file mode 100644 index 8975d9e14..000000000 --- a/frontend/js/carbondb.js +++ /dev/null @@ -1,471 +0,0 @@ - -const getQueryParameters = (name) => { - const urlParams = new URLSearchParams(window.location.search); - if (!urlParams.size) return []; - document.querySelector('#filters-active').style.display = ''; - const allParams = urlParams.getAll(name); - return [...new Set(allParams)]; -} - -const dateTimePicker = () => { - $('#rangestart').calendar({ - type: 'date', - endCalendar: $('#rangeend') - }); - $('#rangeend').calendar({ - type: 'date', - startCalendar: $('#rangestart') - }); -} - -const getChartOptionsScaffold = (chart_type, dimension, unit) => { - const customColors = { - 'carbon': ['#5470C6', '#91CC75', '#EE6666', '#FAC858', '#73C0DE', '#3BA272', '#FC8452', '#9A60B4', '#EA7CCC','#D4A5A5', '#FFD700', '#7B68EE', '#FF69B4', '#2E8B57', '#DAA520', '#CD5C5C', '#4B0082'], - 'energy': ['#5470C6', '#91CC75', '#EE6666', '#FAC858', '#73C0DE', '#3BA272', '#FC8452', '#9A60B4', '#EA7CCC','#D4A5A5', '#FFD700', '#7B68EE', '#FF69B4', '#2E8B57', '#DAA520', '#CD5C5C', '#4B0082'], - 'type': ['#5470C6', '#91CC75', '#EE6666', '#FAC858', '#73C0DE', '#3BA272', '#FC8452', '#9A60B4', '#EA7CCC','#D4A5A5', '#FFD700', '#7B68EE', '#FF69B4', '#2E8B57', '#DAA520', '#CD5C5C', '#4B0082'], - 'machine': ['#FF4500', '#6A5ACD', '#4682B4', '#D2691E', '#FF6347', '#00FA9A', '#FF1493', '#BA55D3', '#800080','#5F9EA0', '#FF8C00', '#4169E1', '#DB7093', '#B0E0E6', '#F4A460', '#8B4513', '#FF00FF'], - 'project': ['#AFEEEE', '#2F4F4F', '#FA8072', '#20B2AA', '#FFFACD', '#D3D3D3', '#40E0D0', '#C71585', '#66CDAA','#FFDAB9', '#A9A9A9', '#8A2BE2', '#B22222', '#F08080'], - 'source': ['#1ABC9C','#2ECC71','#3498DB','#9B59B6','#E74C3C','#F1C40F','#E67E22','#16A085','#27AE60','#2980B9','#8E44AD','#C0392B','#F39C12','#D35400','#34495E'] - } - - if (chart_type == 'bar') { - return { - color: customColors[dimension], - yAxis: { type: 'value', gridIndex: 0, name: `${dimension} ${unit}` }, - xAxis: {type: "category", data: ["Timeline (days)"]}, - series: [], - title: { text: null }, - animation: false, - legend: { - data: [], - bottom: 0, - type: 'scroll', - } - /* toolbox: { - itemSize: 25, - top: 55, - feature: { - dataZoom: { - yAxisIndex: 'none' - }, - restore: {} - } - },*/ - - }; - } else if (chart_type == 'pie') { - return option = { - color: customColors[dimension], - title: { text: null }, - tooltip: { - trigger: 'item' - }, - legend: { - top: '5%', - left: 'right', - type: 'scroll', - orient: 'vertical', - }, - series: [ - { - name: '', - type: 'pie', - radius: ['40%', '70%'], - padAngle: 50, - itemStyle: { - borderRadius: 5 - }, - avoidLabelOverlap: false, - label: { - show: false, - position: 'center' - }, - emphasis: { - label: { - show: true, - fontSize: 40, - fontWeight: 'bold' - } - }, - labelLine: { - show: false - }, - data: [] - } - ] - }; - } -} - -const fillPieChart = (dimension, legend, labels, series) => { - const options = getChartOptionsScaffold('pie', dimension, '[kg]'); - options.title.text = `carbon by ${dimension} [kg]`; - - options.series[0].data = series; - options.legend.data = Array.from(legend); - - options.tooltip = { - trigger: 'item', - formatter: function (params, ticket, callback) { - return `${escapeString(labels[params.dataIndex].key)}
- Carbon: ${escapeString(labels[params.dataIndex].value)} g
- `; - } - - }; - - return options; -} - -const fillBarChart = (y_axis, legend, labels, series) => { - let options = null; - if (y_axis == 'carbon') { - options = getChartOptionsScaffold('bar', y_axis, '[kg]'); - - } else { - options = getChartOptionsScaffold('bar', y_axis, '[kWh]'); - } - options.title.text = `${y_axis} by day`; - - options.series = series; - options.legend.data = Array.from(legend) - - options.tooltip = { - trigger: 'item', - formatter: function (params, ticket, callback) { - return `${escapeString(labels[params.componentIndex].date)}
- Type: ${escapeString(labels[params.componentIndex].type)}
- Value: ${escapeString(labels[params.componentIndex].value)} ${escapeString(labels[params.componentIndex].unit)}
- Project: ${escapeString(labels[params.componentIndex].project)}
- Machine: ${escapeString(labels[params.componentIndex].machine)}
- Source: ${escapeString(labels[params.componentIndex].source)}
- Tags: ${escapeString(labels[params.componentIndex].tags)}
- `; - } - - }; - return options; -} - -const buildQueryParams = () => { - let api_url = `start_date=${$('#rangestart input').val()}`; - api_url = `${api_url}&end_date${$('#rangeend input').val()}`; - - api_url = `${api_url}&types_include${$('#types-include').dropdown('get values').join(',')}`; - api_url = `${api_url}&types_exclude${$('#types-exclude').dropdown('get values').join(',')}`; - - api_url = `${api_url}&tags_include${$('#tags-include').dropdown('get values').join(',')}`; - api_url = `${api_url}&tags_exclude${$('#tags-exclude').dropdown('get values').join(',')}`; - - api_url = `${api_url}&machines_include${$('#machines-include').dropdown('get values').join(',')}`; - api_url = `${api_url}&machines_exclude${$('#machines-exclude').dropdown('get values').join(',')}`; - - api_url = `${api_url}&projects_include${$('#projects-include').dropdown('get values').join(',')}`; - api_url = `${api_url}&projects_exclude${$('#projects-exclude').dropdown('get values').join(',')}`; - - api_url = `${api_url}&sources_include${$('#sources-include').dropdown('get values').join(',')}`; - api_url = `${api_url}&sources_exclude${$('#sources-exclude').dropdown('get values').join(',')}`; - - - return api_url; -} - -const bindRefreshButton = (repo, branch, workflow_id, chart_instance) => { - $('#submit').on('click', async function () { - history.pushState(null, '', `${window.location.origin}${window.location.pathname}?${buildQueryParams()}`); // replace URL to bookmark! - refreshView(); - }); -} - -const processData = (measurements) => { - - const carbon_barchart_data = {legend: new Set(), labels: [], series: []}; - const energy_barchart_data = {legend: new Set(), labels: [], series: []}; - - let piechart_types_data = {legend: new Set(), labels: [], series: []}; - let piechart_machines_data = {legend: new Set(), labels: [], series: []}; - let piechart_projects_data = {legend: new Set(), labels: [], series: []}; - let piechart_sources_data = {legend: new Set(), labels: [], series: []}; - - // we need these to pre-aggregate for pie-charts - // also we need Map as otherwise the order will get skewed and we need aligned order for same colors in charts - const piechart_types_values = new Map(); - const piechart_machines_values = new Map(); - const piechart_projects_values = new Map(); - const piechart_sources_values = new Map(); - - - - let total_carbon = 0; - let total_energy = 0; - const carbon_intensity_list = []; - - measurements.forEach(measurement => { // iterate over all measurements, which are in row order - let [type, project, machine, source, tags, date, energy, carbon, carbon_intensity, record_count] = measurement; - - total_carbon += carbon; - total_energy += energy; - carbon_intensity_list.push(carbon_intensity); - - carbon_barchart_data.series.push({ - type: 'bar', - smooth: true, - stack: date, - name: dimensions_lookup['types'][type] , - data: [carbon], - itemStyle: { - borderWidth: .5, - borderColor: '#000000', - }, - }) - carbon_barchart_data.legend.add(dimensions_lookup['types'][type]) - - carbon_barchart_data.labels.push({ - type: dimensions_lookup['types'][type], - date: date, - project: dimensions_lookup['projects'][project], - machine: dimensions_lookup['machines'][machine], - source: dimensions_lookup['sources'][source], - tags: tags.map( el => dimensions_lookup['tags'][el]), - value: carbon, - unit: 'kg', - }) - - energy_barchart_data.series.push({ - type: 'bar', - smooth: true, - stack: date, - name: dimensions_lookup['types'][type] , - data: [energy], - itemStyle: { - borderWidth: .5, - borderColor: '#000000', - }, - }) - energy_barchart_data.legend.add(dimensions_lookup['types'][type]) - - energy_barchart_data.labels.push({ - type: dimensions_lookup['types'][type], - date: date, - value: energy, - unit: 'kWh', - }) - - if (piechart_machines_values.get(machine) == undefined) piechart_machines_values.set(machine, carbon) - else piechart_machines_values.set(machine, piechart_machines_values.get(machine) + carbon); - - if (piechart_types_values.get(type) == undefined) piechart_types_values.set(type, carbon); - else piechart_types_values.set(type, piechart_types_values.get(type) + carbon); - - if (piechart_projects_values.get(project) == undefined) piechart_projects_values.set(project, carbon); - else piechart_projects_values.set(project, piechart_projects_values.get(project) + carbon); - - if (piechart_sources_values.get(source) == undefined) piechart_sources_values.set(source, carbon); - else piechart_sources_values.set(source, piechart_sources_values.get(source) + carbon); - - - }); - - piechart_machines_data = transformPieChartData(piechart_machines_data, piechart_machines_values, 'machines') - piechart_types_data = transformPieChartData(piechart_types_data, piechart_types_values, 'types') - piechart_projects_data = transformPieChartData(piechart_projects_data, piechart_projects_values, 'projects') - piechart_sources_data = transformPieChartData(piechart_sources_data, piechart_sources_values, 'sources') - - const total_machines = Object.keys(piechart_machines_data).length; - const carbon_per_machine = total_carbon / total_machines; - const carbon_per_project = total_carbon / Object.keys(piechart_projects_data).length; - - const avg_carbon_intensity = carbon_intensity_list.reduce((sum, value) => sum + value, 0) / carbon_intensity_list.length; - - return [carbon_barchart_data, energy_barchart_data, piechart_types_data, piechart_machines_data, piechart_projects_data, piechart_sources_data, total_carbon, total_energy, total_machines, carbon_per_machine, carbon_per_project, avg_carbon_intensity]; -} - -const transformPieChartData = (data, values, dimension) => { - // we might have negative values in CarbonDB, which is fine. But they cannot show in PieCharts. Thus we transform - values.forEach((value, key) => { - data.series.push({ value: Math.abs(value), name: dimensions_lookup[dimension][key] }) - data.legend.add(dimensions_lookup[dimension][key]) - - data.labels.push({ - key: dimensions_lookup[dimension][key], - value: value, - }) - }); - - return data; -} - -const refreshView = async () => { - $('.carbondb-data').hide(); - - for (let instance in chart_instances) { - chart_instances[instance].clear(); - } - - try { - var measurements = await getMeasurements(); - $('#no-data-message').hide(); - } catch (err) { - showNotification('Could not get data from API', err); - $('#no-data-message').show(); - return; - } - - - if (measurements.data.length == 0){ - $('#no-data-message').show(); - showNotification('No data', 'We could not find any data. Please check your date and filter conditions.') - return; - } - - const [carbon_barchart_data, energy_barchart_data, piechart_types_data, piechart_machines_data, piechart_projects_data, piechart_sources_data, total_carbon, total_energy, total_machines, carbon_per_machine, carbon_per_project, avg_carbon_intensity] = processData(measurements.data); - - $('.carbondb-data').show(); - - let options = fillBarChart('carbon', carbon_barchart_data.legend, carbon_barchart_data.labels, carbon_barchart_data.series); - chart_instances['carbondb-barchart-carbon-chart'].setOption(options); - - options = fillBarChart('energy', energy_barchart_data.legend, energy_barchart_data.labels, energy_barchart_data.series); - chart_instances['carbondb-barchart-energy-chart'].setOption(options); - - options = fillPieChart('type', piechart_types_data.legend, piechart_types_data.labels, piechart_types_data.series); - chart_instances['carbondb-piechart-types-chart'].setOption(options); - - options = fillPieChart('machine', piechart_machines_data.legend, piechart_machines_data.labels, piechart_machines_data.series); - chart_instances['carbondb-piechart-machines-chart'].setOption(options); - - options = fillPieChart('project', piechart_projects_data.legend, piechart_projects_data.labels, piechart_projects_data.series); - chart_instances['carbondb-piechart-projects-chart'].setOption(options); - - options = fillPieChart('source', piechart_sources_data.legend, piechart_sources_data.labels, piechart_sources_data.series); - chart_instances['carbondb-piechart-sources-chart'].setOption(options); - - - $('#total-carbon').html(`${total_carbon.toFixed(2)}`); - $('#total-energy').html(`${total_energy.toFixed(2)}`); - $('#total-machines').html(`${total_machines.toFixed(2)}`); - $('#carbon-per-machine').html(`${carbon_per_machine.toFixed(2)}`); - $('#carbon-per-project').html(`${carbon_per_project.toFixed(2)}`); - $('#avg-carbon-intensity').html(`${avg_carbon_intensity.toFixed(2)}`); - -} - -const getMeasurements = async () => { - let start_date = $('#rangestart input').val(); - let end_date = $('#rangeend input').val(); - - if (start_date == '') { - start_date = dateToYMD(new Date((new Date()).setDate((new Date).getDate() -30)), short=true); - } else { - start_date = dateToYMD(new Date(start_date), short=true); - } - if (end_date == '') { - end_date = dateToYMD(new Date(), short=true); - } else { - end_date = dateToYMD(new Date(end_date), short=true); - } - - const types_include = $('#types-include').dropdown('get values').join(','); - const types_exclude = $('#types-exclude').dropdown('get values').join(','); - - const tags_include = $('#tags-include').dropdown('get values').join(','); - const tags_exclude = $('#tags-exclude').dropdown('get values').join(','); - - const machines_include = $('#machines-include').dropdown('get values').join(','); - const machines_exclude = $('#machines-exclude').dropdown('get values').join(','); - - const projects_include = $('#projects-include').dropdown('get values').join(','); - const projects_exclude = $('#projects-exclude').dropdown('get values').join(','); - - const sources_include = $('#sources-include').dropdown('get values').join(','); - const sources_exclude = $('#sources-exclude').dropdown('get values').join(','); - - - return await makeAPICall(`/v2/carbondb?types_include=${types_include}&types_exclude=${types_exclude}&tags_include=${tags_include}&tags_exclude=${tags_exclude}&machines_include=${machines_include}&machines_exclude=${machines_exclude}&projects_include=${projects_include}&projects_exclude=${projects_exclude}&sources_include=${sources_include}&sources_exclude=${sources_exclude}&start_date=${start_date}&end_date=${end_date}`); -} - -const populatePossibleFilters = (filters) => { - for (dimension in filters.data) { - for (element in filters.data[dimension]) { - document.querySelector(`#${dimension}-include`).appendChild(new Option(escapeString(filters.data[dimension][element]), element)); - document.querySelector(`#${dimension}-exclude`).appendChild(new Option(escapeString(filters.data[dimension][element]), element)); - } - } -} - -const selectFilters = (selector, param) => { - const query_params = getQueryParameters(param); - - if (query_params.length <= 0) return; - - const values = query_params[0].split(','); - $(selector).dropdown('set exactly', escapeString(values)); -} - - -// variables global to file -const chart_instances = {}; -let dimensions_lookup = {} - -$(document).ready(function () { - - bindRefreshButton(); - dateTimePicker(); - - $('.ui.accordion').accordion(); - - chart_instances['carbondb-barchart-carbon-chart'] = echarts.init(document.querySelector("#carbondb-barchart-carbon-chart")); - chart_instances['carbondb-barchart-energy-chart'] = echarts.init(document.querySelector("#carbondb-barchart-energy-chart")); - chart_instances['carbondb-piechart-types-chart'] = echarts.init(document.querySelector("#carbondb-piechart-types-chart")); - chart_instances['carbondb-piechart-machines-chart'] = echarts.init(document.querySelector("#carbondb-piechart-machines-chart")); - chart_instances['carbondb-piechart-projects-chart'] = echarts.init(document.querySelector("#carbondb-piechart-projects-chart")); - chart_instances['carbondb-piechart-sources-chart'] = echarts.init(document.querySelector("#carbondb-piechart-sources-chart")); - - window.onresize = function () { // set callback when ever the user changes the viewport - for (let instance in chart_instances) { - chart_instances[instance].resize(); - } - }; - - (async () => { - try { - var filters = await makeAPICall(`/v2/carbondb/filters`); - } catch(err) { - showNotification('Could not get data from API', err); - $('#no-data-message').show(); - $('.carbondb-data').hide(); - return; - } - populatePossibleFilters(filters); - dimensions_lookup = filters.data; - - $('#types-include').dropdown({keepSearchTerm: true}); - $('#types-exclude').dropdown({keepSearchTerm: true}); - $('#tags-include').dropdown({keepSearchTerm: true}); - $('#tags-exclude').dropdown({keepSearchTerm: true}); - $('#machines-include').dropdown({keepSearchTerm: true}); - $('#machines-exclude').dropdown({keepSearchTerm: true}); - $('#projects-include').dropdown({keepSearchTerm: true}); - $('#projects-exclude').dropdown({keepSearchTerm: true}); - $('#sources-include').dropdown({keepSearchTerm: true}); - $('#sources-exclude').dropdown({keepSearchTerm: true}); - - selectFilters('#types-include', 'types_include'); - selectFilters('#types-exclude', 'types_exclude'); - selectFilters('#tags-include', 'tags_include'); - selectFilters('#tags-exclude', 'tags_exclude'); - selectFilters('#machines-include', 'machines_include'); - selectFilters('#machines-exclude', 'machines_exclude'); - selectFilters('#project-include', 'project_include'); - selectFilters('#project-exclude', 'project_exclude'); - selectFilters('#source-include', 'source_include'); - selectFilters('#source-exclude', 'source_exclude'); - - $('#rangestart').calendar('set date', getQueryParameters('start_date')); - $('#rangeend').calendar('set date', getQueryParameters('end_date')); - - refreshView(); - - setTimeout(function(){console.log("Resize"); window.dispatchEvent(new Event('resize'))}, 500); - })(); -}); diff --git a/frontend/js/carbondb.js b/frontend/js/carbondb.js new file mode 120000 index 000000000..78b67a4aa --- /dev/null +++ b/frontend/js/carbondb.js @@ -0,0 +1 @@ +../../ee/frontend/js/carbondb.js \ No newline at end of file diff --git a/frontend/js/helpers/config.js.example b/frontend/js/helpers/config.js.example index f6eb2f8c6..9e95e0966 100644 --- a/frontend/js/helpers/config.js.example +++ b/frontend/js/helpers/config.js.example @@ -2,6 +2,10 @@ API_URL = "__API_URL__" METRICS_URL = "__METRICS_URL__" +ACTIVATE_CARBON_DB = __ACTIVATE_CARBON_DB__; +ACTIVATE_ECO_CI = true; // Eco-CI is always active as open source. But can be deactivated if not needed +ACTIVATE_POWER_HOG = __ACTIVATE_POWER_HOG__; + /* The following are configurations to customize de Detailed Metrics / Compare view according to your needs. The components are fixed, but you can rename then and include different metrics if needed diff --git a/frontend/js/helpers/main.js b/frontend/js/helpers/main.js index 522db56ee..27fbaf999 100644 --- a/frontend/js/helpers/main.js +++ b/frontend/js/helpers/main.js @@ -4,7 +4,7 @@ */ class GMTMenu extends HTMLElement { connectedCallback() { - this.innerHTML = ` + let html_content = ` `; + + this.innerHTML = html_content; } } customElements.define('gmt-menu', GMTMenu); diff --git a/frontend/js/hog-details.js b/frontend/js/hog-details.js deleted file mode 100644 index c279ef83f..000000000 --- a/frontend/js/hog-details.js +++ /dev/null @@ -1,386 +0,0 @@ -$(document).ready(function () { - function getURLParameter(name) { - return new URLSearchParams(window.location.search).get(name); - } - - (async () => { - let mData - const machine_uuid = getURLParameter('machine_uuid') - try { - - var measurements = await makeAPICall(`/v1/hog/machine_details/${machine_uuid}`); - } catch (err) { - showNotification('Could not get data from API', err); - return; - } - if (measurements.data.length == 0){ - showNotification('No data', 'We could not find any data. Did you follow a correct URL?') - return - } - mData = measurements.data.map(item => { - item[0] = new Date(item[0]); - return item; - }); - mData.unshift(['time', 'combined_energy', 'cpu_energy', 'gpu_energy','ane_energy','energy_impact', 'id']) - - const myChart = echarts.init(document.getElementById('chart-container')); - - options = { - legend: { - orient: 'horizontal', - top: 'top', - data: ['combined_energy', 'cpu_energy', 'gpu_energy', 'ane_energy','energy_impact'], - selected: { - 'combined_energy': false, - 'cpu_energy': false, - 'gpu_energy': false, - 'ane_energy': false, - 'energy_impact': true - } - - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'shadow', - label: { - show: true - } - } - }, - dataset: { - source: mData - }, - grid: { - top: '12%', - left: '1%', - right: '10%', - containLabel: true - }, - - xAxis: { - type: 'category', - name: 'Time' - }, - - yAxis: [ - { - type: 'value', - name: 'mJ', - position: 'left', - }, - { - type: 'value', - name: 'energy_impact', - position: 'right', - }, - ], - series: [ - { type: 'bar', yAxisIndex: 0 }, - { type: 'bar', yAxisIndex: 0 }, - { type: 'bar', yAxisIndex: 0 }, - { type: 'bar', yAxisIndex: 0 }, - { type: 'bar', yAxisIndex: 1 }], - calculable: true, - dataZoom: [ - { - show: true, - start: 0, - end: 100 - }, - { - type: 'inside', - start: 0, - end: 100 - }, - { - show: true, - yAxisIndex: 0, - filterMode: 'empty', - width: 30, - height: '80%', - showDataShadow: false, - left: '93%' - } - ], - toolbox: { - show: true, - feature: { - mark: { show: true }, - magicType: { show: true, type: ['line', 'bar', 'stack'] }, - restore: { show: true }, - saveAsImage: { show: true }, - dataZoom: { yAxisIndex: false}, - } - }, - - }; - - - function handleZoomEvent(){ - let zoomTimeout; - $('#table-loader').addClass('active'); - - clearTimeout(zoomTimeout); - - zoomTimeout = setTimeout(async function() { - const dataZoomOption = myChart.getOption().dataZoom[0]; - const startPercent = dataZoomOption.start; - const endPercent = dataZoomOption.end; - const totalDataPoints = mData.length; - const startIndex = Math.floor(startPercent / 100 * totalDataPoints); - const endIndex = Math.ceil(endPercent / 100 * totalDataPoints) - 1; - let firstValue = mData[startIndex]; - let lastValue = mData[endIndex]; - if (firstValue[6] == 'id'){ - firstValue = mData[1]; - } - if(typeof lastValue === "undefined"){ - lastValue = mData[mData.length]; - } - try { - - var coalitions = await makeAPICall(`/v1/hog/coalitions_tasks/${machine_uuid}/${firstValue[6]}/${lastValue[6]}`); - energy_html = ` -
-
-
-
Combined System Energy
- ${coalitions.energy_data[0].toLocaleString()} mJ -
-
-
-
-
Cpu Energy
- ${coalitions.energy_data[1].toLocaleString()} mJ -
-
-
-
-
Gpu Energy
- ${coalitions.energy_data[2].toLocaleString()} mJ -
-
-
-
-
Ane Energy
- ${coalitions.energy_data[3].toLocaleString()} mJ -
-
-
-
-
Energy Impact
- ${coalitions.energy_data[4].toLocaleString()} -
-
-
- ` - $("#energy_segment").html(energy_html) - $('#process-table').DataTable({ - autoWidth: false, - destroy: true, - data: coalitions.data, - columns: [ - { data: 0, title: 'Name'}, - { - data: 1, - title: 'Energy Impact', - className: "dt-body-right", - render: function(el, type, row) { - if (type === 'display' || type === 'filter') { - return (el.toLocaleString()) - } - return el; - } - }, - { - data: 2, - title: 'Mb Read', - className: "dt-body-right", - render: function(el, type, row) { - if (type === 'display' || type === 'filter') { - return Math.trunc(el / 1048576).toLocaleString(); - } - return el; - } - }, - { - data: 3, - title: 'Mb Written', - className: "dt-body-right", - render: function(el, type, row) { - if (type === 'display' || type === 'filter') { - return Math.trunc(el / 1048576).toLocaleString(); - } - return el; - } - }, - { data: 4, title: 'Intr Wakeups',className: "dt-body-right"}, - { data: 5, title: 'Idle Wakeups', className: "dt-body-right"}, - { data: 6, title: 'Avg cpu time %', className: "dt-body-right"}, - - { - data: null, - title: '', - render: function(el, type, row) { - return ``; - }, - orderable: false, - searchable: false - } - ], - deferRender: true, - drawCallback: function(settings) { - $('.js-task-info').click(async function() { - - $("#coaliton-segment").addClass("loading") - $("#task-segment").addClass("loading") - - $('#task-details').modal('show'); - - var tasks = await makeAPICall(`/v1/hog/tasks_details/${machine_uuid}/${$(this).data('start')}/${$(this).data('end')}/${$(this).data('name')}`); - - coalition_string=` -

${tasks.coalitions_data[0]}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AttributeValue
total_energy_impact${tasks.coalitions_data[1]}
total_diskio_bytesread${tasks.coalitions_data[2]}
total_diskio_byteswritten${tasks.coalitions_data[3]}
total_intr_wakeups${tasks.coalitions_data[4]}
total_idle_wakeups${tasks.coalitions_data[5]}
` - const tasks_string = tasks.tasks_data.map(subArr => ` -

${subArr[0]}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AttributeValue
Name${subArr[1]}
Occurrence${subArr[2]}
total_energy_impact${subArr[3]}
cputime_ns${subArr[4]}
bytes_received${subArr[5]}
bytes_sent${subArr[6]}
diskio_bytesread${subArr[7]}
diskio_byteswritten${subArr[8]}
intr_wakeups${subArr[9]}
idle_wakeups${subArr[10]}
- `).join(' '); - $("#coaliton-segment").html(coalition_string) - $("#coaliton-segment").removeClass("loading") - - $("#task-segment").html(tasks_string) - $("#task-segment").removeClass("loading") - }); - }, - order: [], - }); - - - $('#table-loader').removeClass('active'); - - } catch (err) { - showNotification('Could not get data from API', err); - return; - } - }, 1000); - - } - - function focusOnBar(dataIndex) { - const zoomFactor = 8; - const dataLength = mData.length -1 ; - const startPercent = (dataIndex - zoomFactor / 2) / dataLength * 100; - const endPercent = (dataIndex + zoomFactor / 2) / dataLength * 100; - - myChart.setOption({ - dataZoom: [{ - start: Math.max(0, startPercent), - end: Math.min(100, endPercent) - }] - }); - } - myChart.setOption(options); - handleZoomEvent(); - - myChart.on('click', function(params) { - if (params.componentType === 'series' && params.seriesType === 'bar') { - focusOnBar(params.dataIndex); - handleZoomEvent(); - } - }); - - myChart.on('datazoom', function() { - handleZoomEvent(); - }); - - myChart.on('restore', function() { - handleZoomEvent(); - }); - - window.addEventListener('resize', function() { - myChart.resize(); - }); - - - })(); -}); diff --git a/frontend/js/hog-details.js b/frontend/js/hog-details.js new file mode 120000 index 000000000..4a0cd890e --- /dev/null +++ b/frontend/js/hog-details.js @@ -0,0 +1 @@ +../../ee/frontend/js/hog-details.js \ No newline at end of file diff --git a/frontend/js/hog.js b/frontend/js/hog.js deleted file mode 100644 index 5f886583c..000000000 --- a/frontend/js/hog.js +++ /dev/null @@ -1,32 +0,0 @@ -$(document).ready(function () { - - (async () => { - try { - var measurements = await makeAPICall('/v1/hog/top_processes'); - } catch (err) { - showNotification('Could not get data from API', err); - return; - } - $('#process-table').DataTable({ - data: measurements.process_data, - autoWidth: false, - columns: [ - { data: 0, title: 'Name'}, - { - data: 1, - title: 'Energy Impact', - className: "dt-body-right", - render: function(el, type, row) { - if (type === 'display' || type === 'filter') { - return (el.toLocaleString()) - } - return el; - } - }, - ], - deferRender: true, - order: [] // API determines order - }); - $('#machine_count').text(measurements.machine_count); - })(); -}); diff --git a/frontend/js/hog.js b/frontend/js/hog.js new file mode 120000 index 000000000..2f2ee6683 --- /dev/null +++ b/frontend/js/hog.js @@ -0,0 +1 @@ +../../ee/frontend/js/hog.js \ No newline at end of file diff --git a/lib/db.py b/lib/db.py index c23e58509..630e8b010 100644 --- a/lib/db.py +++ b/lib/db.py @@ -1,11 +1,19 @@ #pylint: disable=consider-using-enumerate +import os from psycopg_pool import ConnectionPool import psycopg.rows - +import pytest from lib.global_config import GlobalConfig + +def is_pytest_session(): + return "pytest" in os.environ.get('_', '') + class DB: def __new__(cls): + if is_pytest_session() and GlobalConfig().config['postgresql']['host'] != 'test-green-coding-postgres-container': + pytest.exit(f"You are accessing the live/local database ({GlobalConfig().config['postgresql']['host']}) while running pytest. This might clear the DB. Aborting for security ...", returncode=1) + if not hasattr(cls, 'instance'): cls.instance = super(DB, cls).__new__(cls) return cls.instance diff --git a/lib/global_config.py b/lib/global_config.py index 081b90f01..4513e7df4 100644 --- a/lib/global_config.py +++ b/lib/global_config.py @@ -1,6 +1,7 @@ import os import yaml + class FrozenDict(dict): def __setattr__(self, key, value): raise TypeError("GlobalConfig is immutable once loaded! (__setattr__)") @@ -45,12 +46,10 @@ def __init__(self, config_location=f"{os.path.dirname(os.path.realpath(__file__) with open(config_location, encoding='utf8') as config_file: self.config = freeze_dict(yaml.load(config_file, yaml.FullLoader)) - ## add an override function that will always set the config to a new value def override_config(self, config_location=f"{os.path.dirname(os.path.realpath(__file__))}/../config.yml"): with open(config_location, encoding='utf8') as config_file: self.config = freeze_dict(yaml.load(config_file, yaml.FullLoader)) - if __name__ == '__main__': print(GlobalConfig().config['measurement']) diff --git a/lib/install_shared.sh b/lib/install_shared.sh index b9252ca76..960b3081b 100644 --- a/lib/install_shared.sh +++ b/lib/install_shared.sh @@ -23,6 +23,7 @@ enable_ssl=true ask_ssl=true cert_key='' cert_file='' +enterprise=false function print_message { echo "" @@ -123,6 +124,14 @@ function prepare_config() { eval "${sed_command} -e \"s|__API_URL__|$api_url|\" frontend/js/helpers/config.js" eval "${sed_command} -e \"s|__METRICS_URL__|$metrics_url|\" frontend/js/helpers/config.js" + if [[ $enterprise == true ]]; then + eval "${sed_command} -e \"s|__ACTIVATE_CARBON_DB__|true|\" frontend/js/helpers/config.js" + eval "${sed_command} -e \"s|__ACTIVATE_POWER_HOG__|true|\" frontend/js/helpers/config.js" + else + eval "${sed_command} -e \"s|__ACTIVATE_CARBON_DB__|false|\" frontend/js/helpers/config.js" + eval "${sed_command} -e \"s|__ACTIVATE_POWER_HOG__|false|\" frontend/js/helpers/config.js" + fi + if [[ $enable_ssl == true ]] ; then eval "${sed_command} -e \"s|9142:9142|443:443|\" docker/compose.yml" eval "${sed_command} -e \"s|9142:9142|443:443|\" docker/compose.yml" @@ -203,7 +212,14 @@ function setup_python() { function checkout_submodules() { print_message "Checking out further git submodules ..." - git submodule update --init + + if [[ $(uname) != "Darwin" ]]; then + git submodule update --init lib/sgx-software-enable + fi + git submodule update --init metric_providers/psu/energy/ac/xgboost/machine/model + if [[ $enterprise == true ]] ; then + git submodule update --init ee + fi } function build_binaries() { @@ -265,7 +281,7 @@ function finalize() { -while getopts "p:a:m:nhtbisyrlc:k:" o; do +while getopts "p:a:m:nhtbisyrlc:k:e:" o; do case "$o" in p) db_pw=${OPTARG} @@ -313,10 +329,22 @@ while getopts "p:a:m:nhtbisyrlc:k:" o; do k) cert_key=${OPTARG} ;; + e) + ee_token=${OPTARG} + enterprise=true + ;; esac done +if [[ $enterprise == true ]] ; then + echo "Validating enterprise token" + curl --silent -X POST https://plausible.io/api/event \ + -H 'Content-Type: application/json' \ + --data "{\"name\":\"api_test\",\"url\":\"https://www.green-coding.io/?utm_source=${ee_token}\",\"domain\":\"proxy.green-coding.io\"}" > /dev/null +fi + + if [[ $ask_ssl == true ]] ; then echo "" read -p "Do you want to enable SSL for the API and frontend? (y/N) : " enable_ssl_input diff --git a/metric_providers/psu/energy/ac/xgboost/machine/model b/metric_providers/psu/energy/ac/xgboost/machine/model index 3d44005a8..5b7cc582e 160000 --- a/metric_providers/psu/energy/ac/xgboost/machine/model +++ b/metric_providers/psu/energy/ac/xgboost/machine/model @@ -1 +1 @@ -Subproject commit 3d44005a8fe48d8e43d145d64002797ec44ea516 +Subproject commit 5b7cc582e749ee826fe45379cb1dbe1190a2bacf diff --git a/requirements-dev.txt b/requirements-dev.txt index 80ff89d0e..6b70d3675 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,5 @@ -r requirements.txt pydantic==2.9.2 -pytest==8.3.3 pylint==3.3.1 pytest-randomly==3.16.0 pytest-playwright==0.5.2 \ No newline at end of file diff --git a/tests/README.MD b/tests/README.MD index 270216520..7683124c4 100644 --- a/tests/README.MD +++ b/tests/README.MD @@ -24,12 +24,13 @@ run: `python3 setup-test-env.py` -from the test directory. This will create a copy of the `config.yml` and docker `compose.yml` files that will be used in +from the test directory. This will create a copy of the `config.yml`, `test-config.yml` and docker `compose.yml` files that will be used in the test containers. Please make sure that you have compiled all the metric providers and source code in lib. You can do -this automatically by using the `install.sh` command. +this automatically by using the `install-linux.sh`/`install-mac.sh` command. -You will need to re-run this setup script if new metric providers are added or the config.yml is otherwise changed in a -significant way. +If you have an enterprise / premium license please append `--ee` to the `python3 setup-test-env.py` call. + +You will need to re-run this setup script if you updated GMT. ## Running diff --git a/tests/api/hog_data.py b/tests/api/hog_data.py deleted file mode 100644 index fbd053398..000000000 --- a/tests/api/hog_data.py +++ /dev/null @@ -1 +0,0 @@ -hog_string = '''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''' diff --git a/tests/api/hog_data.py b/tests/api/hog_data.py new file mode 120000 index 000000000..2bbe73f82 --- /dev/null +++ b/tests/api/hog_data.py @@ -0,0 +1 @@ +../../ee/tests/api/hog_data.py \ No newline at end of file diff --git a/tests/api/test_api_carbondb.py b/tests/api/test_api_carbondb.py deleted file mode 100644 index 73cb285cc..000000000 --- a/tests/api/test_api_carbondb.py +++ /dev/null @@ -1,169 +0,0 @@ -import os -import requests -import ipaddress -import time -import math -import json - -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) - -from lib.user import User -from lib.db import DB -from lib.global_config import GlobalConfig -from tests import test_functions as Tests - -API_URL = GlobalConfig().config['cluster']['api_url'] # will be pre-loaded with test-config.yml due to conftest.py - -ENERGY_DATA = { - 'type': 'machine.ci', - 'energy_uj': 1, - 'time': int(time.time() * 1e6), - 'project': 'my-project', - 'machine': 'my-machine', - 'tags': ['mystery', 'cool'] -} - -def test_carbondb_add_unauthenticated(): - user = User(1) - user._capabilities['api']['routes'] = [] - user.update() - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=ENERGY_DATA, timeout=15) - assert response.status_code == 401, Tests.assertion_info('success', response.text) - -def test_carbondb_add(): - - exp_data = ENERGY_DATA.copy() - del exp_data['energy_uj'] - exp_data['energy_kwh'] = 2.7777777777777774e-13 # 1 uJ - exp_data['carbon_kg'] = 2.7777777777777777e-13 # 1e-6J / (3600 * 1000) = kwH = 2.7777777777777774e-13 => * 1000 => 2.77e-10 g = 2.77e-13 kg - exp_data['carbon_intensity_g'] = 1000.0 # because we have no electricitymaps token set - exp_data['latitude'] = 52.53721666833642 - exp_data['longitude'] = 13.42486387066192 - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=ENERGY_DATA, timeout=15) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - data = DB().fetch_one('SELECT * FROM carbondb_data_raw', fetch_mode='dict') - assert data is not None or data != [] - assert_expected_data(exp_data, data) - -def test_carbondb_add_force_ip(): - energydata_modified = ENERGY_DATA.copy() - energydata_modified['ip'] = '1.1.1.1' - - - exp_data = energydata_modified.copy() - del exp_data['energy_uj'] - exp_data['ip_address'] = ipaddress.IPv4Address('1.1.1.1') - exp_data['latitude'] = -27.4766 # Hmm, this can be flaky! But also we want to test the IP API - exp_data['longitude'] = 153.0166 # Hmm, this can be flaky! But also we want to test the IP API - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energydata_modified, timeout=15) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - data = DB().fetch_one('SELECT * FROM carbondb_data_raw', fetch_mode='dict') - assert data is not None or data != [] - assert_expected_data(exp_data, data) - - -def test_carbondb_add_force_carbon_intensity(): - - energydata_modified = ENERGY_DATA.copy() - energydata_modified['carbon_intensity_g'] = 200 - - exp_data = energydata_modified.copy() - del exp_data['energy_uj'] - exp_data['carbon_intensity_g'] = 200 - exp_data['carbon_kg'] = 5.555555555555555e-14 - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energydata_modified, timeout=15) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - data = DB().fetch_one('SELECT * FROM carbondb_data_raw', fetch_mode='dict') - assert data is not None or data != [] - assert_expected_data(exp_data, data) - - -def test_carbondb_missing_values(): - energydata_crap = { - } - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energydata_crap, timeout=15) - assert response.status_code == 422, Tests.assertion_info('success', response.text) - assert response.text == '{"success":false,"err":[{"type":"missing","loc":["body","project"],"msg":"Field required","input":{}},{"type":"missing","loc":["body","machine"],"msg":"Field required","input":{}},{"type":"missing","loc":["body","type"],"msg":"Field required","input":{}},{"type":"missing","loc":["body","time"],"msg":"Field required","input":{}},{"type":"missing","loc":["body","energy_uj"],"msg":"Field required","input":{}}],"body":{}}' - -def test_carbondb_non_int(): - energydata_broken = { - 'type': 123, - 'energy_uj': 'no-int', - 'time': 'no-time', - 'project': 678, - 'machine': 9, - } - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energydata_broken, timeout=15) - assert response.status_code == 422, Tests.assertion_info('success', response.text) - assert response.text == '{"success":false,"err":[{"type":"string_type","loc":["body","project"],"msg":"Input should be a valid string","input":678},{"type":"string_type","loc":["body","machine"],"msg":"Input should be a valid string","input":9},{"type":"string_type","loc":["body","type"],"msg":"Input should be a valid string","input":123},{"type":"int_parsing","loc":["body","time"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"no-time"},{"type":"int_parsing","loc":["body","energy_uj"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"no-int"}],"body":{"type":123,"energy_uj":"no-int","time":"no-time","project":678,"machine":9}}' - -def test_carbondb_superflous(): - energydata_superflous = ENERGY_DATA.copy() - energydata_superflous['no-need'] = 1 - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energydata_superflous, timeout=15) - assert response.status_code == 422, Tests.assertion_info('success', response.text) - assert json.loads(response.text)['err'][0]['type'] == 'extra_forbidden' - assert json.loads(response.text)['err'][0]['loc'] == ['body','no-need'] - -def test_carbondb_empty_filters(): - energydata_modified = ENERGY_DATA.copy() - energydata_modified['type'] = '' - energydata_modified['project'] = '' - energydata_modified['machine'] = '' - energydata_modified['tags'] = ['',''] - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energydata_modified, timeout=15) - assert response.status_code == 422, Tests.assertion_info('success', response.text) - - assert response.text.startswith('''{"success":false,"err":[{"type":"value_error","loc":["body","tags"],"msg":"Value error, The list contains empty elements.","input":["",""],"ctx":{"error":{}}},{"type":"value_error","loc":["body","project"],"msg":"Value error, Value is empty","input":"","ctx":{"error":{}}},{"type":"value_error","loc":["body","machine"],"msg":"Value error, Value is empty","input":"","ctx":{"error":{}}},{"type":"value_error","loc":["body","type"],"msg":"Value error, Value is empty","input":"","ctx":{"error":{}}}]''') - - -def test_carbondb_weird_tags(): - energydata_modified = ENERGY_DATA.copy() - energydata_modified['tags'] = ['öla', ''] - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energydata_modified, timeout=15) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - data = DB().fetch_one('SELECT tags FROM carbondb_data_raw', fetch_mode='dict') - assert data['tags'] == energydata_modified['tags'] - - -def test_carbondb_no_filters(): - - response = requests.get(f"{API_URL}/v2/carbondb/filters", timeout=15, headers={'X-Authentication': 'DEFAULT'}) - assert response.status_code == 200, Tests.assertion_info('success', response.text) - assert response.text == '{"success":true,"data":{"types":null,"tags":null,"machines":null,"projects":null,"sources":null}}' - - - -def test_carbondb_alternative_user_and_data(): - - Tests.import_demo_data() - response = requests.get(f"{API_URL}/v2/carbondb/filters", timeout=15, headers={'X-Authentication': 'DEFAULT'}) - assert response.status_code == 200, Tests.assertion_info('success', response.text) - assert response.text == '{"success":true,"data":{"types":{"1":"machine.test","2":"generator.solar","3":"asdasd","4":"machine.ci","5":"machine.server"},"tags":{"111":"Environment setup (OS ubuntu-24.04","115":"green-coding.ai","118":"green-coding-solutions/ci-carbon-testing","119":"Measurement #1","120":"Environment setup (Python","135":"metrics.green-coding.io"},"machines":{"1":"GCS HQ Solar Panel","5":"metrics.green-coding.io","11":"green-coding.ai","20":"metrics.green-coding.io-alt","22":"ubuntu-latest"},"projects":{"1":"Projekt #1","2":"Projekt #2","3":"Projekt #3","4":"Projekt #4"},"sources":{"1":"UNDEFINED"}}}' - - Tests.insert_user(345, 'ALTERNATIVE-USER-CARBONDB') - response = requests.get(f"{API_URL}/v2/carbondb/filters", timeout=15, headers={'X-Authentication': 'ALTERNATIVE-USER-CARBONDB'}) - assert response.status_code == 200, Tests.assertion_info('success', response.text) - - # no filters again for no user - assert response.text == '{"success":true,"data":{"types":null,"tags":null,"machines":null,"projects":null,"sources":null}}' - - -def assert_expected_data(exp_data, data): - for key in exp_data: - if key == 'ip': - key = 'ip_address' - if isinstance(exp_data[key], float): - assert math.isclose(exp_data[key], data[key], rel_tol=1e-3) , f"{key}: {exp_data[key]} not close to {data[key]} - Raw: {data}" - else: - assert exp_data[key] == data[key], f"{key}: {exp_data[key]} != {data[key]} - Raw: {data}" diff --git a/tests/api/test_api_carbondb.py b/tests/api/test_api_carbondb.py new file mode 120000 index 000000000..974cdc82a --- /dev/null +++ b/tests/api/test_api_carbondb.py @@ -0,0 +1 @@ +../../ee/tests/api/test_api_carbondb.py \ No newline at end of file diff --git a/tests/api/test_api_hog.py b/tests/api/test_api_hog.py deleted file mode 100644 index 9c648bc15..000000000 --- a/tests/api/test_api_hog.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -import os -import requests - -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) - -from lib.db import DB -from lib.global_config import GlobalConfig - -API_URL = GlobalConfig().config['cluster']['api_url'] # will be pre-loaded with test-config.yml due to conftest.py - -import hog_data - - -def test_hogDB_add(): - hog_data_obj = [{ - "time": 1710668240000, - "data": hog_data.hog_string, - "settings": json.dumps({"powermetrics": 5000, "upload_delta": 3, "upload_data": True, "resolve_coalitions": ["com.googlecode.iterm2", "com.apple.terminal", "com.vix.cron"], "client_version": "0.5"}), - "machine_uuid": "371ee758-d4e6-11ee-a082-7e27a1187d3d", - }] - - - response = requests.post(f"{API_URL}/v1/hog/add", json=hog_data_obj, timeout=15) - assert response.status_code == 204, response.text - - queries = ['SELECT * FROM hog_tasks', 'SELECT * FROM hog_coalitions', 'SELECT * FROM hog_measurements'] - for q in queries: - data = DB().fetch_one(q, fetch_mode='dict') - assert data is not None or data != [] diff --git a/tests/api/test_api_hog.py b/tests/api/test_api_hog.py new file mode 120000 index 000000000..134393916 --- /dev/null +++ b/tests/api/test_api_hog.py @@ -0,0 +1 @@ +../../ee/tests/api/test_api_hog.py \ No newline at end of file diff --git a/tests/cron/test_carbondb_compress.py b/tests/cron/test_carbondb_compress.py deleted file mode 100644 index 6a62a75fa..000000000 --- a/tests/cron/test_carbondb_compress.py +++ /dev/null @@ -1,270 +0,0 @@ -import os -import requests -import math - -CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) - -from lib.global_config import GlobalConfig -from lib.db import DB -from tests import test_functions as Tests - -from cron.carbondb_compress import compress_carbondb_raw -from cron.carbondb_copy_over_and_remove_duplicates import copy_over_gmt, copy_over_eco_ci, remove_duplicates - - -API_URL = GlobalConfig().config['cluster']['api_url'] # will be pre-loaded with test-config.yml due to conftest.py - -from tests.api.test_api_eco_ci import MEASUREMENT_MODEL_NEW as ECO_CI_DATA -from tests.api.test_api_carbondb import ENERGY_DATA - -FROM_J_TO_KWH = 3_600 * 1_000 -FROM_UJ_TO_J = FROM_UG_TO_G = 1_000_000 -FROM_MJ_TO_J = FROM_G_TO_KG = 1_000 -FROM_UG_TO_KG = 1_000_000_000 - -def test_insert_and_compress_eco_ci_with_two_users(): - - RANGE_AMOUNT = 10 - - Tests.insert_user(345, 'ALTERNATIVE-USER') - - eco_ci_data = ECO_CI_DATA.copy() - eco_ci_data['carbon_ug'] = 7 - - eco_ci_data_2 = ECO_CI_DATA.copy() - eco_ci_data_2['carbon_ug'] = 400 - - - for _ in range(RANGE_AMOUNT): - response = requests.post(f"{API_URL}/v2/ci/measurement/add", json=eco_ci_data, timeout=15) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - response = requests.post(f"{API_URL}/v2/ci/measurement/add", json=eco_ci_data_2, timeout=15, headers={'X-Authentication': 'ALTERNATIVE-USER'}) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - copy_over_eco_ci() - compress_carbondb_raw() - - data = DB().fetch_one('SELECT * FROM carbondb_data WHERE date = CURRENT_DATE AND user_id = 1', fetch_mode='dict') - - energy_kWh = eco_ci_data['energy_uj'] * RANGE_AMOUNT / FROM_UJ_TO_J / FROM_J_TO_KWH - assert math.isclose(data['energy_kwh_sum'], energy_kWh, rel_tol=1e-5) - - carbon_kg = eco_ci_data['carbon_ug'] * RANGE_AMOUNT / FROM_UG_TO_KG - assert math.isclose(data['carbon_kg_sum'], carbon_kg, rel_tol=1e-5) - - carbon_intensity_g_avg = int((carbon_kg/energy_kWh)*1000) - assert carbon_intensity_g_avg-1 <= data['carbon_intensity_g_avg'] <= carbon_intensity_g_avg+1 # different rounding can cost 1 g different intensity. No need to be more precise here given that the margin of error in the source data is not know - - data = DB().fetch_one('SELECT * FROM carbondb_data WHERE date = CURRENT_DATE and user_id = 345', fetch_mode='dict') - - energy_kWh = eco_ci_data_2['energy_uj'] * RANGE_AMOUNT / FROM_UJ_TO_J / FROM_J_TO_KWH - assert math.isclose(data['energy_kwh_sum'], energy_kWh, rel_tol=1e-5) - - carbon_kg = eco_ci_data_2['carbon_ug'] * RANGE_AMOUNT / FROM_UG_TO_KG - assert math.isclose(data['carbon_kg_sum'], carbon_kg, rel_tol=1e-5) - - carbon_intensity_g_avg = int((carbon_kg/energy_kWh)*1000) - assert carbon_intensity_g_avg-1 <= data['carbon_intensity_g_avg'] <= carbon_intensity_g_avg+1 # different rounding can cost 1 g different intensity. No need to be more precise here given that the margin of error in the source data is not know - -def test_insert_and_compress_carbondb_with_two_users(): - - RANGE_AMOUNT = 10 - - Tests.insert_user(345, 'ALTERNATIVE-USER') - - energy_data = ENERGY_DATA.copy() - energy_data['carbon_intensity_g'] = 200 - - energy_data_2 = ENERGY_DATA.copy() - energy_data_2['energy_uj'] = 300 - energy_data_2['carbon_intensity_g'] = 200 - - - for _ in range(RANGE_AMOUNT): - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energy_data, timeout=15) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energy_data_2, timeout=15, headers={'X-Authentication': 'ALTERNATIVE-USER'}) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - compress_carbondb_raw() - - data = DB().fetch_one('SELECT * FROM carbondb_data WHERE date = CURRENT_DATE and user_id = 1', fetch_mode='dict') - energy_kWh = energy_data['energy_uj'] * RANGE_AMOUNT / FROM_UJ_TO_J / FROM_J_TO_KWH - assert math.isclose(data['energy_kwh_sum'], energy_kWh, rel_tol=1e-5) - - carbon_kg = energy_kWh * energy_data['carbon_intensity_g'] / FROM_G_TO_KG - assert math.isclose(data['carbon_kg_sum'], carbon_kg, rel_tol=1e-5) - - assert data['carbon_intensity_g_avg'] == energy_data['carbon_intensity_g'] - - - data = DB().fetch_one('SELECT * FROM carbondb_data WHERE date = CURRENT_DATE and user_id = 345', fetch_mode='dict') - energy_kWh = energy_data_2['energy_uj'] * RANGE_AMOUNT / FROM_UJ_TO_J / FROM_J_TO_KWH - assert math.isclose(data['energy_kwh_sum'], energy_kWh, rel_tol=1e-5) - - carbon_kg = energy_kWh * energy_data_2['carbon_intensity_g'] / FROM_G_TO_KG - assert math.isclose(data['carbon_kg_sum'], carbon_kg, rel_tol=1e-5) - - assert data['carbon_intensity_g_avg'] == energy_data_2['carbon_intensity_g'] - - -def test_insert_and_compress_gmt_with_two_users(): - - AMOUNT_OF_GMT_RUNS = 9 - - Tests.insert_user(345, 'ALTERNATIVE-USER') - Tests.insert_user(2, 'ALTERNATIVE-USER2') - - # Add two demo machines - DB().query("INSERT INTO machines (id, description) VALUES(100, 'Machine 100')") - DB().query("INSERT INTO machines (id, description) VALUES(101, 'Machine 101')") - - # Add 7 runs on different machines and dates - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000000','-', '-', '-', 100, 2, NOW())") - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000001','-', '-', '-', 100, 2, NOW())") - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000002','-', '-', '-', 100, 2, NOW())") - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000003','-', '-', '-', 100, 2, NOW())") - - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000004','-', '-', '-', 100, 2, NOW() + INTERVAL '1 DAY')") - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000005','-', '-', '-', 100, 2, NOW() + INTERVAL '1 DAY')") - - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000006','-', '-', '-', 100, 345, NOW())") - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000007','-', '-', '-', 100, 345, NOW())") - - DB().query("INSERT INTO runs(id, uri, branch, filename, machine_id, user_id, created_at) VALUES('00000000-0000-0000-0000-000000000008','-', '-', '-', 101, 345, NOW())") - - ## Add some fake metrics - - for i in range(0,9): - DB().query('''INSERT INTO phase_stats(run_id, metric, detail_name, phase, value, type, unit) - VALUES - (%s,'psu_energy_ac_mcp_machine','[machine]','004_[RUNTIME]',5434523, 'TOTAL', 'mJ') - ''', params=(f"00000000-0000-0000-0000-00000000000{i}", )) - DB().query('''INSERT INTO phase_stats(run_id, metric, detail_name, phase, value, type, unit) - VALUES - (%s,'embodied_carbon_share_machine','[machine]','004_[RUNTIME]',14610, 'TOTAL', 'ug') - ''', params=(f"00000000-0000-0000-0000-00000000000{i}", )) - - # Add another phase just for testing purposes is group works correctly - DB().query('''INSERT INTO phase_stats(run_id, metric, detail_name, phase, value, type, unit) - VALUES - (%s,'other_carbon_share_machine','[machine]','001_[BASELINE]',14610, 'TOTAL', 'ug') - ''', params=('00000000-0000-0000-0000-000000000004', )) - - DB().query('''INSERT INTO phase_stats(run_id, metric, detail_name, phase, value, type, unit) - VALUES - (%s,'another_carbon_share_machine','[machine]','001_[BASELINE]',14610, 'TOTAL', 'ug') - ''', params=('00000000-0000-0000-0000-000000000004', )) - - DB().query('''INSERT INTO phase_stats(run_id, metric, detail_name, phase, value, type, unit) - VALUES - (%s,'other_energy_share_machine','[machine]','001_[RUNTIME]',5434523123, 'TOTAL', 'mJ') - ''', params=('00000000-0000-0000-0000-000000000004', )) - - assert DB().fetch_one('SELECT COUNT(id) FROM phase_stats')[0] == AMOUNT_OF_GMT_RUNS*2+3, 'Unexpected amount of row. Maybe demo data present?' - - - copy_over_gmt() - - assert DB().fetch_one('SELECT COUNT(id) FROM carbondb_data_raw')[0] == AMOUNT_OF_GMT_RUNS, 'LEFT JOIN expanded the rows! Should be no more than 10' - - for j in range(2,5): - copy_over_gmt() - - assert DB().fetch_one('SELECT COUNT(id) FROM carbondb_data_raw')[0] == AMOUNT_OF_GMT_RUNS * j, 'Copy did not results in identical rows' - - remove_duplicates() - - assert DB().fetch_one('SELECT COUNT(id) FROM carbondb_data_raw')[0] == AMOUNT_OF_GMT_RUNS, 'Remove duplicates did not remove identical rows' - - - data = DB().fetch_one("SELECT id, source, type, machine, project FROM carbondb_data_raw WHERE user_id = 345 AND machine = 'Machine 101'", fetch_mode='dict') - - assert data['type'] == 'machine.server' - assert data['machine'] == 'Machine 101' - assert data['source'] == 'Green Metrics Tool' - assert data['project'] == 'Energy-ID' - - compress_carbondb_raw() - - assert DB().fetch_one('SELECT COUNT(id) FROM carbondb_data_raw')[0] == AMOUNT_OF_GMT_RUNS, 'Compress mingled with raw data. This should not happen' - - assert DB().fetch_one('SELECT COUNT(id) FROM carbondb_data')[0] == 4, 'Row compression resulted in more / less than 4 rows.' - - - data = DB().fetch_one('SELECT energy_kwh_sum, carbon_kg_sum, carbon_intensity_g_avg, record_count FROM carbondb_data WHERE date = CURRENT_DATE AND user_id = 2', fetch_mode='dict') - - record_count = 4 - energy = 5434523*record_count / FROM_MJ_TO_J / FROM_J_TO_KWH # value initially was in mJ - carbon = 14610*record_count / FROM_UG_TO_KG - - assert data['record_count'] == record_count - assert math.isclose(data['energy_kwh_sum'], energy, rel_tol=1e-6) - assert math.isclose(data['carbon_kg_sum'], carbon, rel_tol=1e-6) - - data = DB().fetch_one("SELECT energy_kwh_sum, carbon_kg_sum, carbon_intensity_g_avg, record_count FROM carbondb_data WHERE date = CURRENT_DATE + INTERVAL '1 DAY' AND user_id = 2", fetch_mode='dict') - - record_count = 2 - energy = 5434523*record_count / FROM_MJ_TO_J / FROM_J_TO_KWH # value initially was in mJ - energy += 5434523123 / FROM_MJ_TO_J / FROM_J_TO_KWH # other_energy_share_machine - in mJ - - carbon = 14610*record_count / FROM_UG_TO_KG - carbon += 14610 / FROM_UG_TO_KG # other_carbon_share_machine - carbon += 14610 / FROM_UG_TO_KG # another_carbon_share_machine - - assert data['record_count'] == record_count - assert math.isclose(data['energy_kwh_sum'], energy, rel_tol=1e-6) - assert math.isclose(data['carbon_kg_sum'], carbon, rel_tol=1e-6) - - machine_100_filter_id = DB().fetch_one("SELECT id FROM carbondb_machines WHERE user_id = 345 AND machine = 'Machine 100'")[0] - data = DB().fetch_one("SELECT energy_kwh_sum, carbon_kg_sum, carbon_intensity_g_avg, record_count FROM carbondb_data WHERE user_id = 345 AND machine = %s", params=(machine_100_filter_id, ), fetch_mode='dict') - - record_count = 2 - energy = 5434523*record_count / FROM_MJ_TO_J / FROM_J_TO_KWH # value initially was in mJ - - carbon = 14610*record_count / FROM_UG_TO_KG - - assert data['record_count'] == record_count - assert math.isclose(data['energy_kwh_sum'], energy, rel_tol=1e-6) - assert math.isclose(data['carbon_kg_sum'], carbon, rel_tol=1e-6) - - machine_101_filter_id = DB().fetch_one("SELECT id FROM carbondb_machines WHERE user_id = 345 AND machine = 'Machine 101'")[0] - data = DB().fetch_one("SELECT energy_kwh_sum, carbon_kg_sum, carbon_intensity_g_avg, record_count FROM carbondb_data WHERE user_id = 345 AND machine = %s", params=(machine_101_filter_id, ), fetch_mode='dict') - - record_count = 1 - energy = 5434523*record_count / FROM_MJ_TO_J / FROM_J_TO_KWH # value initially was in mJ - - carbon = 14610*record_count / FROM_UG_TO_KG - - assert data['record_count'] == record_count - assert math.isclose(data['energy_kwh_sum'], energy, rel_tol=1e-6) - assert math.isclose(data['carbon_kg_sum'], carbon, rel_tol=1e-6) - - - -def test_big_values(): - - energy_data = ENERGY_DATA.copy() - energy_data['carbon_intensity_g'] = 200 - energy_data['energy_uj'] = 12741278312 - - RANGE_AMOUNT=5_000 - - for _ in range(RANGE_AMOUNT): - - response = requests.post(f"{API_URL}/v2/carbondb/add", json=energy_data, timeout=15) - assert response.status_code == 204, Tests.assertion_info('success', response.text) - - compress_carbondb_raw() - - data = DB().fetch_one('SELECT * FROM carbondb_data WHERE date = CURRENT_DATE and user_id = 1', fetch_mode='dict') - energy_kWh = (energy_data['energy_uj']*RANGE_AMOUNT)/(1_000_000*3_600*1_000) - assert math.isclose(data['energy_kwh_sum'], energy_kWh, rel_tol=1e-5) - - carbon_kg = (energy_kWh*energy_data['carbon_intensity_g'])/1_000 - assert math.isclose(data['carbon_kg_sum'], carbon_kg, rel_tol=1e-5) - - assert data['carbon_intensity_g_avg'] == energy_data['carbon_intensity_g'] diff --git a/tests/cron/test_carbondb_compress.py b/tests/cron/test_carbondb_compress.py new file mode 120000 index 000000000..e3471737d --- /dev/null +++ b/tests/cron/test_carbondb_compress.py @@ -0,0 +1 @@ +../../ee/tests/cron/test_carbondb_compress.py \ No newline at end of file diff --git a/tests/frontend/test_frontend.py b/tests/frontend/test_frontend.py index b05db8fed..395227824 100644 --- a/tests/frontend/test_frontend.py +++ b/tests/frontend/test_frontend.py @@ -6,12 +6,11 @@ GMT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../..') from lib.global_config import GlobalConfig -from lib.db import DB from tests import test_functions as Tests from playwright.sync_api import sync_playwright -from api.main import CI_Measurement +from api.object_specifications import CI_Measurement page = None @@ -58,13 +57,11 @@ def setup_and_cleanup_test(): def test_home(): - page.goto(GlobalConfig().config['cluster']['metrics_url'] + '/index.html') value = page.locator("#runs-table > tbody > tr:nth-child(2) > td:nth-child(1) > a").text_content() assert value== 'Stress Test #2' - def test_eco_ci_demo_data(): page.goto(GlobalConfig().config['cluster']['metrics_url'] + '/index.html') @@ -358,101 +355,3 @@ def test_settings(): time_series_avg_display = page.locator('#time-series-avg-display').text_content() assert time_series_avg_display.strip() == 'Currently not showing AVG in time series' - - -def test_carbondb_display(): - - - page.goto(GlobalConfig().config['cluster']['metrics_url'] + '/index.html') - page.get_by_role("link", name="CarbonDB").click() - - page.locator('#carbondb-barchart-carbon-chart canvas').wait_for(timeout=3_000) # will wait for - - page.locator('#show-filters').click() - - page.locator("input[name=range_start]").fill('2024-10-01') - page.locator("input[name=range_end]").fill('2024-10-31') - page.get_by_role("button", name="Refresh").click() - - page.locator('#carbondb-barchart-carbon-chart canvas').wait_for(timeout=3_000) # will wait for - - total_carbon = page.locator('#total-carbon').text_content() - assert total_carbon.strip() == '1477.00' - -def test_carbondb_manual_add(): - - try: - DB().query(''' - INSERT INTO carbondb_data(id, type,project,machine,source,tags,date,energy_kwh_sum,carbon_kg_sum,carbon_intensity_g_avg,record_count,user_id) - VALUES - (3000, 1,1,1,1,ARRAY[]::int[],E'2024-10-10',1.25e3,300,283,7,1); - ''') - - - page.goto(GlobalConfig().config['cluster']['metrics_url'] + '/index.html') - page.get_by_role("link", name="CarbonDB").click() - - page.screenshot(path="problem.png") - page.locator('#carbondb-barchart-carbon-chart canvas').wait_for(timeout=3_000) # will wait for - - page.locator('#show-filters').click() - - page.locator("input[name=range_start]").fill('2024-10-01') - page.locator("input[name=range_end]").fill('2024-10-31') - page.get_by_role("button", name="Refresh").click() - - page.locator('#carbondb-barchart-carbon-chart canvas').wait_for(timeout=3_000) # will wait for - - total_carbon = page.locator('#total-carbon').text_content() - assert total_carbon.strip() == '1777.00' - - finally: - DB().query('DELETE FROM carbondb_data WHERE id = 3000;') - - -def test_carbondb_display_xss_tags(): - - try: - DB().query(''' - INSERT INTO carbondb_tags(id,tag,user_id) - VALUES (999,'',1); - INSERT INTO carbondb_data(id,type,project,machine,source,tags,date,energy_kwh_sum,carbon_kg_sum,carbon_intensity_g_avg,record_count,user_id) - VALUES - (3000,1,1,1,1,ARRAY[999],E'2024-10-10',1.25e3,300,283,7,1); - ''') - - - page.goto(GlobalConfig().config['cluster']['metrics_url'] + '/index.html') - page.get_by_role('link', name="CarbonDB").click() - - page.locator('#carbondb-barchart-carbon-chart canvas').wait_for(timeout=3_000) # will wait for - - page.locator('#show-filters').click() - - all_tags = page.locator('#tags-include').locator("option").evaluate_all("options => options.map(option => option.textContent)") - assert '' not in all_tags - assert '<script>alert(XSS);</script>' in all_tags - - - finally: - DB().query('DELETE FROM carbondb_data WHERE id = 3000;') - -def test_carbondb_no_display_different_user(): - Tests.insert_user(234, 'NO-CARBONDB') - - - page.goto(GlobalConfig().config['cluster']['metrics_url'] + '/index.html') - page.get_by_role("link", name="Authentication").click() - - page.locator('#authentication-token').fill('NO-CARBONDB') - page.locator('#save-authentication-token').click() - page.locator('#token-details-message').wait_for(state='visible') - - page.get_by_role("link", name="CarbonDB").click() - - page.wait_for_load_state("load") # ALL JS should be done - - page.locator('#total-carbon').wait_for(state='hidden') - assert page.locator('#total-carbon').text_content().strip() == '--' # nothing to show - - page.locator('#no-data-message').wait_for(state='visible') diff --git a/tests/frontend/test_frontend_ee.py b/tests/frontend/test_frontend_ee.py new file mode 120000 index 000000000..cb33acd7d --- /dev/null +++ b/tests/frontend/test_frontend_ee.py @@ -0,0 +1 @@ +../../ee/tests/frontend/test_frontend_ee.py \ No newline at end of file diff --git a/tests/setup-test-env.py b/tests/setup-test-env.py index ed7bd0a3b..da67464f1 100644 --- a/tests/setup-test-env.py +++ b/tests/setup-test-env.py @@ -99,6 +99,18 @@ def edit_compose_file(): with open(test_compose_path, 'w', encoding='utf8') as test_compose_file: yaml.dump(compose, test_compose_file) +def create_test_config_file(ee=False): + print('Creating test-config.yml...') + + with open('test-config.yml.example', 'r', encoding='utf-8') as file: + content = file.read() + + if ee: + print('Activating enterprise ...') + content = content.replace('#ee_token:', 'ee_token:') + + with open('test-config.yml', 'w', encoding='utf-8') as file: + file.write(content) def edit_etc_hosts(): subprocess.run(['./edit-etc-hosts.sh'], check=True) @@ -112,9 +124,13 @@ def build_test_docker_image(): parser = argparse.ArgumentParser() parser.add_argument('--no-docker-build', action='store_true', help='Do not build the docker image') + parser.add_argument('--ee', action='store_true', + help='Enable enterprise tests') + args = parser.parse_args() copy_sql_structure() + create_test_config_file(args.ee) edit_compose_file() edit_etc_hosts() if not args.no_docker_build: diff --git a/tests/test-config.yml b/tests/test-config.yml.example similarity index 99% rename from tests/test-config.yml rename to tests/test-config.yml.example index 3a8fb721b..5a81b69cc 100644 --- a/tests/test-config.yml +++ b/tests/test-config.yml.example @@ -109,3 +109,4 @@ optimization: - example_optimization_test electricity_maps_token: 'testing' +#ee_token: 'testing' diff --git a/tests/test_functions.py b/tests/test_functions.py index 3deb2bbd3..6946282e5 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -49,7 +49,14 @@ def build_image_fixture(): # should be preceded by a yield statement and on autouse def reset_db(): - DB().query('DROP schema "public" CASCADE') + # DB().query('DROP schema "public" CASCADE') # we do not want to call DB commands. Reason being is that because of a misconfiguration we could be sending this to the live DB + subprocess.run( + ['docker', 'exec', '--user', 'postgres', 'test-green-coding-postgres-container', 'bash', '-c', 'psql -d test-green-coding --port 9573 -c \'DROP schema "public" CASCADE\' '], + check=True, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + encoding='UTF-8' + ) subprocess.run( ['docker', 'exec', '--user', 'postgres', 'test-green-coding-postgres-container', 'bash', '-c', 'psql --port 9573 < ./docker-entrypoint-initdb.d/structure.sql'], check=True,