From 36e8e8bd65e8082b856e1b30d1f501590376c5de Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 12:26:14 +0100 Subject: [PATCH 01/13] Add /admin/runs/ endpoint --- scheduler/app/faas_scheduler/models.py | 16 +++++--- scheduler/app/faas_scheduler/utils.py | 24 ++++++++++++ scheduler/app/flask_server.py | 54 ++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/scheduler/app/faas_scheduler/models.py b/scheduler/app/faas_scheduler/models.py index e2d38cc..c19261b 100644 --- a/scheduler/app/faas_scheduler/models.py +++ b/scheduler/app/faas_scheduler/models.py @@ -50,26 +50,30 @@ def __init__( self.started_at = started_at self.operate_from = operate_from - def to_dict(self): + def to_dict(self, include_context_data=True, include_output=True): from faas_scheduler.utils import datetime_to_isoformat_timestr - return { + entry = { "id": self.id, "dtable_uuid": self.dtable_uuid, "owner": self.owner, "script_name": self.script_name, - "context_data": ( - json.loads(self.context_data) if self.context_data else None - ), "started_at": datetime_to_isoformat_timestr(self.started_at), "finished_at": self.finished_at and datetime_to_isoformat_timestr(self.finished_at), "success": self.success, "return_code": self.return_code, - "output": self.output, "operate_from": self.operate_from, } + if include_context_data: + entry['context_data'] = json.loads(self.context_data) if self.context_data else None + + if include_output: + entry['output'] = self.output + + return entry + class DTableRunScriptStatistics(Base): __tablename__ = "dtable_run_script_statistics" diff --git a/scheduler/app/faas_scheduler/utils.py b/scheduler/app/faas_scheduler/utils.py index f43af3d..b33f709 100644 --- a/scheduler/app/faas_scheduler/utils.py +++ b/scheduler/app/faas_scheduler/utils.py @@ -3,10 +3,12 @@ import logging import requests from datetime import datetime +from typing import List, Tuple from uuid import UUID from tzlocal import get_localzone from sqlalchemy import desc, text +from sqlalchemy.orm import load_only from faas_scheduler.models import ScriptLog import sys @@ -496,6 +498,28 @@ def get_run_script_statistics_by_month( return month.strftime("%Y-%m"), total_count, results +def get_script_runs(db_session, org_id, base_uuid, start, end, page, per_page) -> Tuple[List[ScriptLog], int]: + fields = [ScriptLog.id, ScriptLog.dtable_uuid, ScriptLog.owner, ScriptLog.org_id, ScriptLog.script_name, ScriptLog.started_at, ScriptLog.finished_at, ScriptLog.success, ScriptLog.return_code, ScriptLog.operate_from] + query = db_session.query(ScriptLog).options(load_only(*fields)) + + if org_id: + query = query.filter_by(org_id=org_id) + + if base_uuid: + query = query.filter_by(dtable_uuid=base_uuid) + + if start: + query = query.filter(ScriptLog.started_at >= start) + + if end: + query = query.filter(ScriptLog.started_at <= end) + + total_count = query.count() + runs = query.limit(per_page).offset((page-1) * per_page).all() + + return runs, total_count + + def datetime_to_isoformat_timestr(datetime_obj): if not datetime_obj: return "" diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 77feb16..828d1dc 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -13,6 +13,7 @@ from database import DBSession from faas_scheduler.utils import ( check_auth_token, + get_script_runs, run_script, get_script, add_script, @@ -385,6 +386,59 @@ def base_run_python_statistics(): return get_scripts_running_statistics_by_request(request, target="base") +# List all runs +@app.route('/admin/runs', methods=['GET']) +def list_runs(): + if not check_auth_token(request): + return make_response(("Forbidden: the auth token is not correct.", 403)) + + # org_id and base_uuid are optional + org_id = request.args.get("org_id") + base_uuid = request.args.get('base_uuid') + + if request.args.get('start'): + try: + start = datetime.strptime(request.args.get("start"), "%Y-%m-%d") + except: + return {'error': 'Invalid value for start parameter'}, 400 + else: + start = None + + if request.args.get('end'): + try: + end = datetime.strptime(request.args.get("end"), "%Y-%m-%d") + except: + return {'error': 'Invalid value for end parameter'}, 400 + else: + end = None + + try: + page = int(request.args.get("page", "1")) + except ValueError: + return {'error': 'page must be an integer'}, 400 + + try: + per_page = int(request.args.get("per_page", "20")) + except ValueError: + return {'error': 'per_page must be an integer'}, 400 + + if per_page > 1000: + return {'error': 'per_page cannot be greater than 1000'}, 400 + + db_session = DBSession() + + try: + runs, total_count = get_script_runs(db_session, org_id, base_uuid, start, end, page, per_page) + except Exception as e: + logger.exception(e) + return make_response(("Internal server error", 500)) + finally: + db_session.close() + + runs = [r.to_dict(include_context_data=False, include_output=False) for r in runs] + + return {"runs": runs, "count": total_count} + if __name__ == "__main__": http_server = WSGIServer(("127.0.0.1", 5055), app) http_server.serve_forever() From 8aba8e7398e4a4d22b6fd301df39e4261571ae9e Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 12:32:58 +0100 Subject: [PATCH 02/13] Fix off-by-one error in handling of end parameter --- scheduler/app/flask_server.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 828d1dc..7c0f872 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -5,7 +5,7 @@ import os import json import logging -from datetime import datetime +from datetime import datetime, timedelta from flask import Flask, request, make_response from gevent.pywsgi import WSGIServer from concurrent.futures import ThreadPoolExecutor @@ -407,6 +407,8 @@ def list_runs(): if request.args.get('end'): try: end = datetime.strptime(request.args.get("end"), "%Y-%m-%d") + # Add one day since a date parsed by strptime defaults to midnight + end = end + timedelta(days=1) except: return {'error': 'Invalid value for end parameter'}, 400 else: From 19684cb9f2c7399abd0cb26eb5f4997616716033 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 18:30:14 +0100 Subject: [PATCH 03/13] Add trailing slash --- scheduler/app/flask_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 7c0f872..05be704 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -387,7 +387,7 @@ def base_run_python_statistics(): # List all runs -@app.route('/admin/runs', methods=['GET']) +@app.route('/admin/runs/', methods=['GET']) def list_runs(): if not check_auth_token(request): return make_response(("Forbidden: the auth token is not correct.", 403)) From 0bec847bdceb485f3200c9f5aa83bd2bc532b8af Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 18:30:38 +0100 Subject: [PATCH 04/13] Set default value of per_page parameter to 100 --- scheduler/app/flask_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 05be704..442c8aa 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -420,7 +420,7 @@ def list_runs(): return {'error': 'page must be an integer'}, 400 try: - per_page = int(request.args.get("per_page", "20")) + per_page = int(request.args.get("per_page", "100")) except ValueError: return {'error': 'per_page must be an integer'}, 400 From 73867f2f52433dfd8995a167a02edf42371d1333 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 18:32:15 +0100 Subject: [PATCH 05/13] Add endpoint to get statistics by base UUID --- scheduler/app/faas_scheduler/utils.py | 47 ++++++++++++++++++++++- scheduler/app/flask_server.py | 55 +++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/scheduler/app/faas_scheduler/utils.py b/scheduler/app/faas_scheduler/utils.py index b33f709..f3e1ce7 100644 --- a/scheduler/app/faas_scheduler/utils.py +++ b/scheduler/app/faas_scheduler/utils.py @@ -3,11 +3,11 @@ import logging import requests from datetime import datetime -from typing import List, Tuple +from typing import List, Optional, Tuple from uuid import UUID from tzlocal import get_localzone -from sqlalchemy import desc, text +from sqlalchemy import case, desc, func, text from sqlalchemy.orm import load_only from faas_scheduler.models import ScriptLog @@ -520,6 +520,49 @@ def get_script_runs(db_session, org_id, base_uuid, start, end, page, per_page) - return runs, total_count +def get_statistics_grouped_by_base(db_session, org_id: int, start: Optional[datetime], end: Optional[datetime], page: int, per_page: int) -> Tuple[List[dict], int]: + fields = [ + ScriptLog.dtable_uuid, + func.count(ScriptLog.id).label('number_of_runs'), + # This calls MariaDB's TIMESTAMPDIFF() function with microsecond precision to prevent rounding errors + # Note: Scripts that haven't finished yet are simply ignored + func.sum(func.timestampdiff(text('MICROSECOND'), ScriptLog.started_at, ScriptLog.finished_at) / 1_000_000).label('total_run_time'), + # FIXME: manualy -> manually + func.count(case((ScriptLog.operate_from == 'manualy', 1))).label('triggered_manually'), + func.count(case((ScriptLog.operate_from == 'automation-rule', 1))).label('triggered_by_automation_rule'), + func.count(case((ScriptLog.success == True, 1))).label('successful_runs'), + func.count(case((ScriptLog.success == False, 1))).label('unsuccessful_runs'), + ] + + query = db_session.query(*fields) \ + .filter_by(org_id=org_id) \ + .group_by(ScriptLog.dtable_uuid) + + if start: + query = query.filter(ScriptLog.started_at >= start) + + if end: + query = query.filter(ScriptLog.started_at <= end) + + total_count = query.count() + rows = query.limit(per_page).offset((page-1) * per_page).all() + + results = [] + + for row in rows: + results.append({'' + 'base_uuid': row.dtable_uuid, + 'number_of_runs': row.number_of_runs, + # int() is required since MariaDB returns total_run_time as a string + 'total_run_time': int(row.total_run_time), + 'triggered_manually': row.triggered_manually, + 'triggered_by_automation_rule': row.triggered_by_automation_rule, + 'successful_runs': row.successful_runs, + 'unsuccessful_runs': row.unsuccessful_runs, + }) + + return results, total_count + def datetime_to_isoformat_timestr(datetime_obj): if not datetime_obj: return "" diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 442c8aa..31253f0 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -14,6 +14,7 @@ from faas_scheduler.utils import ( check_auth_token, get_script_runs, + get_statistics_grouped_by_base, run_script, get_script, add_script, @@ -441,6 +442,60 @@ def list_runs(): return {"runs": runs, "count": total_count} + +# Get run statistics grouped by base UUID +@app.route('/admin/statistics/by-base/', methods=['GET']) +def get_run_statistics_grouped_by_base(): + if not check_auth_token(request): + return make_response(("Forbidden: the auth token is not correct.", 403)) + + org_id = request.args.get("org_id") + if not org_id: + return {'error': 'org_id is required'}, 400 + + if request.args.get('start'): + try: + start = datetime.strptime(request.args.get("start"), "%Y-%m-%d") + except: + return {'error': 'Invalid value for start parameter'}, 400 + else: + start = None + + if request.args.get('end'): + try: + end = datetime.strptime(request.args.get("end"), "%Y-%m-%d") + # Add one day since a date parsed by strptime defaults to midnight + end = end + timedelta(days=1) + except: + return {'error': 'Invalid value for end parameter'}, 400 + else: + end = None + + try: + page = int(request.args.get("page", "1")) + except ValueError: + return {'error': 'page must be an integer'}, 400 + + try: + per_page = int(request.args.get("per_page", "100")) + except ValueError: + return {'error': 'per_page must be an integer'}, 400 + + if per_page > 1000: + return {'error': 'per_page cannot be greater than 1000'}, 400 + + db_session = DBSession() + + try: + results, total_count = get_statistics_grouped_by_base(db_session, org_id, start, end, page, per_page) + except Exception as e: + logger.exception(e) + return make_response(("Internal server error", 500)) + finally: + db_session.close() + + return {"results": results, "total_count": total_count} + if __name__ == "__main__": http_server = WSGIServer(("127.0.0.1", 5055), app) http_server.serve_forever() From e48a9b34001dd9e439d76ad57db085dd9677d3cb Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 18:41:50 +0100 Subject: [PATCH 06/13] Fix code style issues --- scheduler/app/faas_scheduler/models.py | 6 +- scheduler/app/faas_scheduler/utils.py | 80 ++++++++++++++++++-------- scheduler/app/flask_server.py | 53 +++++++++-------- 3 files changed, 90 insertions(+), 49 deletions(-) diff --git a/scheduler/app/faas_scheduler/models.py b/scheduler/app/faas_scheduler/models.py index c19261b..5f76c20 100644 --- a/scheduler/app/faas_scheduler/models.py +++ b/scheduler/app/faas_scheduler/models.py @@ -67,10 +67,12 @@ def to_dict(self, include_context_data=True, include_output=True): } if include_context_data: - entry['context_data'] = json.loads(self.context_data) if self.context_data else None + entry["context_data"] = ( + json.loads(self.context_data) if self.context_data else None + ) if include_output: - entry['output'] = self.output + entry["output"] = self.output return entry diff --git a/scheduler/app/faas_scheduler/utils.py b/scheduler/app/faas_scheduler/utils.py index f3e1ce7..5ea4c73 100644 --- a/scheduler/app/faas_scheduler/utils.py +++ b/scheduler/app/faas_scheduler/utils.py @@ -498,8 +498,21 @@ def get_run_script_statistics_by_month( return month.strftime("%Y-%m"), total_count, results -def get_script_runs(db_session, org_id, base_uuid, start, end, page, per_page) -> Tuple[List[ScriptLog], int]: - fields = [ScriptLog.id, ScriptLog.dtable_uuid, ScriptLog.owner, ScriptLog.org_id, ScriptLog.script_name, ScriptLog.started_at, ScriptLog.finished_at, ScriptLog.success, ScriptLog.return_code, ScriptLog.operate_from] +def get_script_runs( + db_session, org_id, base_uuid, start, end, page, per_page +) -> Tuple[List[ScriptLog], int]: + fields = [ + ScriptLog.id, + ScriptLog.dtable_uuid, + ScriptLog.owner, + ScriptLog.org_id, + ScriptLog.script_name, + ScriptLog.started_at, + ScriptLog.finished_at, + ScriptLog.success, + ScriptLog.return_code, + ScriptLog.operate_from, + ] query = db_session.query(ScriptLog).options(load_only(*fields)) if org_id: @@ -515,28 +528,46 @@ def get_script_runs(db_session, org_id, base_uuid, start, end, page, per_page) - query = query.filter(ScriptLog.started_at <= end) total_count = query.count() - runs = query.limit(per_page).offset((page-1) * per_page).all() + runs = query.limit(per_page).offset((page - 1) * per_page).all() return runs, total_count -def get_statistics_grouped_by_base(db_session, org_id: int, start: Optional[datetime], end: Optional[datetime], page: int, per_page: int) -> Tuple[List[dict], int]: +def get_statistics_grouped_by_base( + db_session, + org_id: int, + start: Optional[datetime], + end: Optional[datetime], + page: int, + per_page: int, +) -> Tuple[List[dict], int]: fields = [ ScriptLog.dtable_uuid, - func.count(ScriptLog.id).label('number_of_runs'), + func.count(ScriptLog.id).label("number_of_runs"), # This calls MariaDB's TIMESTAMPDIFF() function with microsecond precision to prevent rounding errors # Note: Scripts that haven't finished yet are simply ignored - func.sum(func.timestampdiff(text('MICROSECOND'), ScriptLog.started_at, ScriptLog.finished_at) / 1_000_000).label('total_run_time'), + func.sum( + func.timestampdiff( + text("MICROSECOND"), ScriptLog.started_at, ScriptLog.finished_at + ) + / 1_000_000 + ).label("total_run_time"), # FIXME: manualy -> manually - func.count(case((ScriptLog.operate_from == 'manualy', 1))).label('triggered_manually'), - func.count(case((ScriptLog.operate_from == 'automation-rule', 1))).label('triggered_by_automation_rule'), - func.count(case((ScriptLog.success == True, 1))).label('successful_runs'), - func.count(case((ScriptLog.success == False, 1))).label('unsuccessful_runs'), + func.count(case((ScriptLog.operate_from == "manualy", 1))).label( + "triggered_manually" + ), + func.count(case((ScriptLog.operate_from == "automation-rule", 1))).label( + "triggered_by_automation_rule" + ), + func.count(case((ScriptLog.success == True, 1))).label("successful_runs"), + func.count(case((ScriptLog.success == False, 1))).label("unsuccessful_runs"), ] - query = db_session.query(*fields) \ - .filter_by(org_id=org_id) \ + query = ( + db_session.query(*fields) + .filter_by(org_id=org_id) .group_by(ScriptLog.dtable_uuid) + ) if start: query = query.filter(ScriptLog.started_at >= start) @@ -545,24 +576,27 @@ def get_statistics_grouped_by_base(db_session, org_id: int, start: Optional[date query = query.filter(ScriptLog.started_at <= end) total_count = query.count() - rows = query.limit(per_page).offset((page-1) * per_page).all() + rows = query.limit(per_page).offset((page - 1) * per_page).all() results = [] for row in rows: - results.append({'' - 'base_uuid': row.dtable_uuid, - 'number_of_runs': row.number_of_runs, - # int() is required since MariaDB returns total_run_time as a string - 'total_run_time': int(row.total_run_time), - 'triggered_manually': row.triggered_manually, - 'triggered_by_automation_rule': row.triggered_by_automation_rule, - 'successful_runs': row.successful_runs, - 'unsuccessful_runs': row.unsuccessful_runs, - }) + results.append( + { + "base_uuid": row.dtable_uuid, + "number_of_runs": row.number_of_runs, + # int() is required since MariaDB returns total_run_time as a string + "total_run_time": int(row.total_run_time), + "triggered_manually": row.triggered_manually, + "triggered_by_automation_rule": row.triggered_by_automation_rule, + "successful_runs": row.successful_runs, + "unsuccessful_runs": row.unsuccessful_runs, + } + ) return results, total_count + def datetime_to_isoformat_timestr(datetime_obj): if not datetime_obj: return "" diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 31253f0..aedb64e 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -388,50 +388,52 @@ def base_run_python_statistics(): # List all runs -@app.route('/admin/runs/', methods=['GET']) +@app.route("/admin/runs/", methods=["GET"]) def list_runs(): if not check_auth_token(request): return make_response(("Forbidden: the auth token is not correct.", 403)) # org_id and base_uuid are optional org_id = request.args.get("org_id") - base_uuid = request.args.get('base_uuid') + base_uuid = request.args.get("base_uuid") - if request.args.get('start'): + if request.args.get("start"): try: start = datetime.strptime(request.args.get("start"), "%Y-%m-%d") - except: - return {'error': 'Invalid value for start parameter'}, 400 + except ValueError: + return {"error": "Invalid value for start parameter"}, 400 else: start = None - if request.args.get('end'): + if request.args.get("end"): try: end = datetime.strptime(request.args.get("end"), "%Y-%m-%d") # Add one day since a date parsed by strptime defaults to midnight end = end + timedelta(days=1) - except: - return {'error': 'Invalid value for end parameter'}, 400 + except ValueError: + return {"error": "Invalid value for end parameter"}, 400 else: end = None try: page = int(request.args.get("page", "1")) except ValueError: - return {'error': 'page must be an integer'}, 400 + return {"error": "page must be an integer"}, 400 try: per_page = int(request.args.get("per_page", "100")) except ValueError: - return {'error': 'per_page must be an integer'}, 400 + return {"error": "per_page must be an integer"}, 400 if per_page > 1000: - return {'error': 'per_page cannot be greater than 1000'}, 400 + return {"error": "per_page cannot be greater than 1000"}, 400 db_session = DBSession() try: - runs, total_count = get_script_runs(db_session, org_id, base_uuid, start, end, page, per_page) + runs, total_count = get_script_runs( + db_session, org_id, base_uuid, start, end, page, per_page + ) except Exception as e: logger.exception(e) return make_response(("Internal server error", 500)) @@ -444,50 +446,52 @@ def list_runs(): # Get run statistics grouped by base UUID -@app.route('/admin/statistics/by-base/', methods=['GET']) +@app.route("/admin/statistics/by-base/", methods=["GET"]) def get_run_statistics_grouped_by_base(): if not check_auth_token(request): return make_response(("Forbidden: the auth token is not correct.", 403)) org_id = request.args.get("org_id") if not org_id: - return {'error': 'org_id is required'}, 400 + return {"error": "org_id is required"}, 400 - if request.args.get('start'): + if request.args.get("start"): try: start = datetime.strptime(request.args.get("start"), "%Y-%m-%d") - except: - return {'error': 'Invalid value for start parameter'}, 400 + except ValueError: + return {"error": "Invalid value for start parameter"}, 400 else: start = None - if request.args.get('end'): + if request.args.get("end"): try: end = datetime.strptime(request.args.get("end"), "%Y-%m-%d") # Add one day since a date parsed by strptime defaults to midnight end = end + timedelta(days=1) - except: - return {'error': 'Invalid value for end parameter'}, 400 + except ValueError: + return {"error": "Invalid value for end parameter"}, 400 else: end = None try: page = int(request.args.get("page", "1")) except ValueError: - return {'error': 'page must be an integer'}, 400 + return {"error": "page must be an integer"}, 400 try: per_page = int(request.args.get("per_page", "100")) except ValueError: - return {'error': 'per_page must be an integer'}, 400 + return {"error": "per_page must be an integer"}, 400 if per_page > 1000: - return {'error': 'per_page cannot be greater than 1000'}, 400 + return {"error": "per_page cannot be greater than 1000"}, 400 db_session = DBSession() try: - results, total_count = get_statistics_grouped_by_base(db_session, org_id, start, end, page, per_page) + results, total_count = get_statistics_grouped_by_base( + db_session, org_id, start, end, page, per_page + ) except Exception as e: logger.exception(e) return make_response(("Internal server error", 500)) @@ -496,6 +500,7 @@ def get_run_statistics_grouped_by_base(): return {"results": results, "total_count": total_count} + if __name__ == "__main__": http_server = WSGIServer(("127.0.0.1", 5055), app) http_server.serve_forever() From 2ece4e42898fab29ecdd0dff9a2f6f3f23252c5a Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 18:47:27 +0100 Subject: [PATCH 07/13] Ignore pylint error --- scheduler/app/faas_scheduler/utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scheduler/app/faas_scheduler/utils.py b/scheduler/app/faas_scheduler/utils.py index 5ea4c73..d63a9f9 100644 --- a/scheduler/app/faas_scheduler/utils.py +++ b/scheduler/app/faas_scheduler/utils.py @@ -541,6 +541,9 @@ def get_statistics_grouped_by_base( page: int, per_page: int, ) -> Tuple[List[dict], int]: + # pylint: disable=E1102 + # False positive caused by https://github.com/pylint-dev/pylint/issues/8138 + fields = [ ScriptLog.dtable_uuid, func.count(ScriptLog.id).label("number_of_runs"), From 722cbbe7d377ddefa20b9dfefacfaf8470ff5862 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 18:49:50 +0100 Subject: [PATCH 08/13] Remove FIXME comment to make Pylint happy --- scheduler/app/faas_scheduler/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/scheduler/app/faas_scheduler/utils.py b/scheduler/app/faas_scheduler/utils.py index d63a9f9..28e272d 100644 --- a/scheduler/app/faas_scheduler/utils.py +++ b/scheduler/app/faas_scheduler/utils.py @@ -555,7 +555,6 @@ def get_statistics_grouped_by_base( ) / 1_000_000 ).label("total_run_time"), - # FIXME: manualy -> manually func.count(case((ScriptLog.operate_from == "manualy", 1))).label( "triggered_manually" ), From f60dc10a6ef991e9ddfa853fe804660aac8a28d9 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Fri, 31 Oct 2025 19:13:08 +0100 Subject: [PATCH 09/13] Fix key --- scheduler/app/flask_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index aedb64e..5d7aff6 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -442,7 +442,7 @@ def list_runs(): runs = [r.to_dict(include_context_data=False, include_output=False) for r in runs] - return {"runs": runs, "count": total_count} + return {"runs": runs, "total_count": total_count} # Get run statistics grouped by base UUID From e15ca6a30ff73c72e0c83110ee1fb6ba96a94aa0 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Mon, 3 Nov 2025 17:54:28 +0100 Subject: [PATCH 10/13] Accept start/end parameters in ISO format --- scheduler/app/flask_server.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 5d7aff6..8141174 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -399,7 +399,7 @@ def list_runs(): if request.args.get("start"): try: - start = datetime.strptime(request.args.get("start"), "%Y-%m-%d") + start = datetime.fromisoformat(request.args.get("start")) except ValueError: return {"error": "Invalid value for start parameter"}, 400 else: @@ -407,9 +407,7 @@ def list_runs(): if request.args.get("end"): try: - end = datetime.strptime(request.args.get("end"), "%Y-%m-%d") - # Add one day since a date parsed by strptime defaults to midnight - end = end + timedelta(days=1) + end = datetime.fromisoformat(request.args.get("end")) except ValueError: return {"error": "Invalid value for end parameter"}, 400 else: @@ -457,7 +455,7 @@ def get_run_statistics_grouped_by_base(): if request.args.get("start"): try: - start = datetime.strptime(request.args.get("start"), "%Y-%m-%d") + start = datetime.fromisoformat(request.args.get("start")) except ValueError: return {"error": "Invalid value for start parameter"}, 400 else: @@ -465,9 +463,7 @@ def get_run_statistics_grouped_by_base(): if request.args.get("end"): try: - end = datetime.strptime(request.args.get("end"), "%Y-%m-%d") - # Add one day since a date parsed by strptime defaults to midnight - end = end + timedelta(days=1) + end = datetime.fromisoformat(request.args.get("end")) except ValueError: return {"error": "Invalid value for end parameter"}, 400 else: From 22d1c1685c1d1640ef2d15e99b2745799a251962 Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Mon, 3 Nov 2025 19:12:20 +0100 Subject: [PATCH 11/13] Add endpoint to get statistics grouped by day --- scheduler/app/faas_scheduler/utils.py | 70 +++++++++++++++++++++++++++ scheduler/app/flask_server.py | 64 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/scheduler/app/faas_scheduler/utils.py b/scheduler/app/faas_scheduler/utils.py index 28e272d..eff5530 100644 --- a/scheduler/app/faas_scheduler/utils.py +++ b/scheduler/app/faas_scheduler/utils.py @@ -599,6 +599,76 @@ def get_statistics_grouped_by_base( return results, total_count +def get_statistics_grouped_by_day( + db_session, + org_id: int, + base_uuid: Optional[str], + start: Optional[datetime], + end: Optional[datetime], + page: int, + per_page: int, +) -> Tuple[List[dict], int]: + # pylint: disable=E1102 + # False positive caused by https://github.com/pylint-dev/pylint/issues/8138 + + fields = [ + func.date(ScriptLog.started_at).label("date"), + func.count(ScriptLog.id).label("number_of_runs"), + # This calls MariaDB's TIMESTAMPDIFF() function with microsecond precision to prevent rounding errors + # Note: Scripts that haven't finished yet are simply ignored + func.sum( + func.timestampdiff( + text("MICROSECOND"), ScriptLog.started_at, ScriptLog.finished_at + ) + / 1_000_000 + ).label("total_run_time"), + func.count(case((ScriptLog.operate_from == "manualy", 1))).label( + "triggered_manually" + ), + func.count(case((ScriptLog.operate_from == "automation-rule", 1))).label( + "triggered_by_automation_rule" + ), + func.count(case((ScriptLog.success == True, 1))).label("successful_runs"), + func.count(case((ScriptLog.success == False, 1))).label("unsuccessful_runs"), + ] + + query = ( + db_session.query(*fields) + .filter_by(org_id=org_id) + .group_by(func.date(ScriptLog.started_at)) + ) + + if base_uuid: + query = query.filter(ScriptLog.dtable_uuid == base_uuid) + + if start: + query = query.filter(ScriptLog.started_at >= start) + + if end: + query = query.filter(ScriptLog.started_at <= end) + + total_count = query.count() + rows = query.limit(per_page).offset((page - 1) * per_page).all() + + results = [] + + for row in rows: + results.append( + { + "date": row.date.strftime("%Y-%m-%d"), + "number_of_runs": row.number_of_runs, + # int() is required since MariaDB returns total_run_time as a string + "total_run_time": int(row.total_run_time), + "triggered_manually": row.triggered_manually, + "triggered_by_automation_rule": row.triggered_by_automation_rule, + "successful_runs": row.successful_runs, + "unsuccessful_runs": row.unsuccessful_runs, + } + ) + + return results, total_count + + def datetime_to_isoformat_timestr(datetime_obj): if not datetime_obj: return "" diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 8141174..53f41e8 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -15,6 +15,7 @@ check_auth_token, get_script_runs, get_statistics_grouped_by_base, + get_statistics_grouped_by_day, run_script, get_script, add_script, @@ -497,6 +498,69 @@ def get_run_statistics_grouped_by_base(): return {"results": results, "total_count": total_count} +# Get run statistics grouped by day +@app.route("/admin/statistics/by-day/", methods=["GET"]) +def get_run_statistics_grouped_by_day(): + if not check_auth_token(request): + return make_response(("Forbidden: the auth token is not correct.", 403)) + + org_id = request.args.get("org_id") + if not org_id: + return {"error": "org_id is required"}, 400 + + # base_uuid is optional + base_uuid = request.args.get("base_uuid") + + if request.args.get("start"): + try: + start = datetime.fromisoformat(request.args.get("start")) + except ValueError: + return {"error": "Invalid value for start parameter"}, 400 + else: + start = None + + if request.args.get("end"): + try: + end = datetime.fromisoformat(request.args.get("end")) + except ValueError: + return {"error": "Invalid value for end parameter"}, 400 + else: + end = None + + try: + page = int(request.args.get("page", "1")) + except ValueError: + return {"error": "page must be an integer"}, 400 + + try: + per_page = int(request.args.get("per_page", "100")) + except ValueError: + return {"error": "per_page must be an integer"}, 400 + + if per_page > 1000: + return {"error": "per_page cannot be greater than 1000"}, 400 + + db_session = DBSession() + + try: + results, total_count = get_statistics_grouped_by_day( + db_session=db_session, + org_id=org_id, + base_uuid=base_uuid, + start=start, + end=end, + page=page, + per_page=per_page, + ) + except Exception as e: + logger.exception(e) + return make_response(("Internal server error", 500)) + finally: + db_session.close() + + return {"results": results, "total_count": total_count} + + if __name__ == "__main__": http_server = WSGIServer(("127.0.0.1", 5055), app) http_server.serve_forever() From 8d986d7324a3e9ea9bc3141d5a9c5fb16fe7115c Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Mon, 3 Nov 2025 19:15:36 +0100 Subject: [PATCH 12/13] Remove unused import --- scheduler/app/flask_server.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 53f41e8..7de71f8 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -5,7 +5,7 @@ import os import json import logging -from datetime import datetime, timedelta +from datetime import datetime from flask import Flask, request, make_response from gevent.pywsgi import WSGIServer from concurrent.futures import ThreadPoolExecutor From fdb71cd05823124cad107c506fce8c15d3672bbf Mon Sep 17 00:00:00 2001 From: Simon Hammes Date: Tue, 11 Nov 2025 13:52:42 +0100 Subject: [PATCH 13/13] Fix handling of end parameter --- scheduler/app/faas_scheduler/utils.py | 8 ++++++++ scheduler/app/flask_server.py | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/scheduler/app/faas_scheduler/utils.py b/scheduler/app/faas_scheduler/utils.py index eff5530..331a3f1 100644 --- a/scheduler/app/faas_scheduler/utils.py +++ b/scheduler/app/faas_scheduler/utils.py @@ -683,6 +683,14 @@ def datetime_to_isoformat_timestr(datetime_obj): return "" +def is_date_yyyy_mm_dd(value: str) -> bool: + try: + datetime.strptime(value, "%Y-%m-%d") + return True + except ValueError: + return False + + def uuid_str_to_32_chars(uuid_str): return uuid_str.replace("-", "") diff --git a/scheduler/app/flask_server.py b/scheduler/app/flask_server.py index 7de71f8..fd1047e 100644 --- a/scheduler/app/flask_server.py +++ b/scheduler/app/flask_server.py @@ -5,7 +5,7 @@ import os import json import logging -from datetime import datetime +from datetime import datetime, timedelta from flask import Flask, request, make_response from gevent.pywsgi import WSGIServer from concurrent.futures import ThreadPoolExecutor @@ -16,6 +16,7 @@ get_script_runs, get_statistics_grouped_by_base, get_statistics_grouped_by_day, + is_date_yyyy_mm_dd, run_script, get_script, add_script, @@ -411,6 +412,11 @@ def list_runs(): end = datetime.fromisoformat(request.args.get("end")) except ValueError: return {"error": "Invalid value for end parameter"}, 400 + + if is_date_yyyy_mm_dd(request.args.get("end")): + # If a plain date was passed in (i.e. without time information), + # we need to add 1 day to ensure that the results for the last day are included + end += timedelta(days=1) else: end = None @@ -467,6 +473,11 @@ def get_run_statistics_grouped_by_base(): end = datetime.fromisoformat(request.args.get("end")) except ValueError: return {"error": "Invalid value for end parameter"}, 400 + + if is_date_yyyy_mm_dd(request.args.get("end")): + # If a plain date was passed in (i.e. without time information), + # we need to add 1 day to ensure that the results for the last day are included + end += timedelta(days=1) else: end = None @@ -524,6 +535,11 @@ def get_run_statistics_grouped_by_day(): end = datetime.fromisoformat(request.args.get("end")) except ValueError: return {"error": "Invalid value for end parameter"}, 400 + + if is_date_yyyy_mm_dd(request.args.get("end")): + # If a plain date was passed in (i.e. without time information), + # we need to add 1 day to ensure that the results for the last day are included + end += timedelta(days=1) else: end = None