aboutsummaryrefslogtreecommitdiffstats
path: root/csit.infra.dash/app/cdash
diff options
context:
space:
mode:
Diffstat (limited to 'csit.infra.dash/app/cdash')
-rw-r--r--csit.infra.dash/app/cdash/__init__.py144
-rw-r--r--csit.infra.dash/app/cdash/comparisons/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/comparisons/comparisons.py53
-rw-r--r--csit.infra.dash/app/cdash/comparisons/layout.py1342
-rw-r--r--csit.infra.dash/app/cdash/comparisons/tables.py348
-rw-r--r--csit.infra.dash/app/cdash/coverage/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/coverage/coverage.py51
-rw-r--r--csit.infra.dash/app/cdash/coverage/layout.py753
-rw-r--r--csit.infra.dash/app/cdash/coverage/tables.py315
-rw-r--r--csit.infra.dash/app/cdash/data/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/coverage_rls2306_devicebin0 -> 5373 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/coverage_rls2306_ndrpdrbin0 -> 11868 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/coverage_rls2310_devicebin0 -> 5373 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/coverage_rls2310_ndrpdrbin0 -> 11868 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/coverage_rls2402_devicebin0 -> 5373 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/coverage_rls2402_ndrpdrbin0 -> 11868 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_hoststackbin0 -> 7882 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_mrrbin0 -> 7919 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_ndrpdrbin0 -> 13081 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_hoststackbin0 -> 7882 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_mrrbin0 -> 7919 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_ndrpdrbin0 -> 15173 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_hoststackbin0 -> 8303 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_mrrbin0 -> 10179 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_ndrpdrbin0 -> 15173 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/statisticsbin0 -> 4398 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/trending_hoststackbin0 -> 9628 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/trending_mrrbin0 -> 9832 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/trending_ndrpdrbin0 -> 16091 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/_metadata/trending_soakbin0 -> 9328 bytes
-rw-r--r--csit.infra.dash/app/cdash/data/data.py421
-rw-r--r--csit.infra.dash/app/cdash/data/data.yaml488
l---------csit.infra.dash/app/cdash/jumpavg1
-rw-r--r--csit.infra.dash/app/cdash/news/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/news/layout.py508
-rw-r--r--csit.infra.dash/app/cdash/news/news.py56
-rw-r--r--csit.infra.dash/app/cdash/news/tables.py176
-rw-r--r--csit.infra.dash/app/cdash/report/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/report/graphs.py311
-rw-r--r--csit.infra.dash/app/cdash/report/layout.py1332
-rw-r--r--csit.infra.dash/app/cdash/report/layout.yaml156
-rw-r--r--csit.infra.dash/app/cdash/report/report.py52
-rw-r--r--csit.infra.dash/app/cdash/routes.py38
-rw-r--r--csit.infra.dash/app/cdash/search/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/search/layout.py948
-rw-r--r--csit.infra.dash/app/cdash/search/layout.yaml276
-rw-r--r--csit.infra.dash/app/cdash/search/search.py52
-rw-r--r--csit.infra.dash/app/cdash/search/tables.py123
-rw-r--r--csit.infra.dash/app/cdash/static/dist/img/favicon.svg348
-rw-r--r--csit.infra.dash/app/cdash/static/img/logo.svg348
-rw-r--r--csit.infra.dash/app/cdash/static/sass/_bootswatch.scss178
-rw-r--r--csit.infra.dash/app/cdash/static/sass/_variables.scss103
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_accordion.scss149
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_alert.scss71
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_badge.scss38
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_breadcrumb.scss40
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_button-group.scss142
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_buttons.scss201
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_card.scss234
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_carousel.scss229
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_close.scss40
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_containers.scss41
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_dropdown.scss249
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_forms.scss9
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_functions.scss302
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_grid.scss33
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_helpers.scss10
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_images.scss42
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_list-group.scss192
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_maps.scss54
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_mixins.scss43
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_modal.scss237
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_nav.scss172
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_navbar.scss278
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_offcanvas.scss144
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_pagination.scss109
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_placeholders.scss51
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_popover.scss196
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_progress.scss59
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_reboot.scss610
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_root.scss73
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_spinners.scss85
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_tables.scss164
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_toasts.scss71
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_tooltip.scss120
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_transitions.scss27
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_type.scss106
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_utilities.scss647
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/_variables.scss1634
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-grid.scss64
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-reboot.scss9
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-utilities.scss15
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap.scss51
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_floating-labels.scss75
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-check.scss175
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-control.scss194
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-range.scss91
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-select.scss71
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-text.scss11
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_input-group.scss132
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_labels.scss36
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_validation.scss12
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_clearfix.scss3
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_color-bg.scss10
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_colored-links.scss12
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_position.scss36
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_ratio.scss26
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_stacks.scss15
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_stretched-link.scss15
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_text-truncation.scss7
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_visually-hidden.scss8
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_vr.scss8
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_alert.scss15
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_backdrop.scss14
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_banner.scss9
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_border-radius.scss78
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_box-shadow.scss18
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_breakpoints.scss127
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_buttons.scss70
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_caret.scss64
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_clearfix.scss9
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_color-scheme.scss7
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_container.scss11
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_deprecate.scss10
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_forms.scss152
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_gradients.scss47
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_grid.scss151
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_image.scss16
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_list-group.scss24
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_lists.scss7
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_pagination.scss10
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_reset-text.scss17
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_resize.scss6
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_table-variants.scss24
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_text-truncate.scss8
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_transition.scss26
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_utilities.scss97
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_visually-hidden.scss29
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/utilities/_api.scss47
-rw-r--r--csit.infra.dash/app/cdash/static/sass/bootstrap/vendor/_rfs.scss354
-rw-r--r--csit.infra.dash/app/cdash/static/sass/lux.scss9
-rw-r--r--csit.infra.dash/app/cdash/stats/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/stats/graphs.py129
-rw-r--r--csit.infra.dash/app/cdash/stats/layout.py853
-rw-r--r--csit.infra.dash/app/cdash/stats/layout.yaml79
-rw-r--r--csit.infra.dash/app/cdash/stats/stats.py58
-rw-r--r--csit.infra.dash/app/cdash/templates/base_layout.jinja271
-rw-r--r--csit.infra.dash/app/cdash/templates/dash_layout.jinja217
-rw-r--r--csit.infra.dash/app/cdash/templates/layout.jinja224
-rw-r--r--csit.infra.dash/app/cdash/trending/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/trending/graphs.py679
-rw-r--r--csit.infra.dash/app/cdash/trending/layout.py1721
-rw-r--r--csit.infra.dash/app/cdash/trending/layout.yaml201
-rw-r--r--csit.infra.dash/app/cdash/trending/trending.py52
-rw-r--r--csit.infra.dash/app/cdash/utils/__init__.py12
-rw-r--r--csit.infra.dash/app/cdash/utils/anomalies.py69
-rw-r--r--csit.infra.dash/app/cdash/utils/constants.py469
-rw-r--r--csit.infra.dash/app/cdash/utils/control_panel.py87
-rw-r--r--csit.infra.dash/app/cdash/utils/telemetry_data.py362
-rw-r--r--csit.infra.dash/app/cdash/utils/tooltips.yaml51
-rw-r--r--csit.infra.dash/app/cdash/utils/trigger.py65
-rw-r--r--csit.infra.dash/app/cdash/utils/url_processing.py99
-rw-r--r--csit.infra.dash/app/cdash/utils/utils.py895
163 files changed, 24444 insertions, 0 deletions
diff --git a/csit.infra.dash/app/cdash/__init__.py b/csit.infra.dash/app/cdash/__init__.py
new file mode 100644
index 0000000000..3d3f2001a3
--- /dev/null
+++ b/csit.infra.dash/app/cdash/__init__.py
@@ -0,0 +1,144 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Initialize Flask app.
+"""
+
+import logging
+import pandas as pd
+
+from flask import Flask
+from flask_assets import Environment, Bundle
+
+from .utils.constants import Constants as C
+from .data.data import Data
+
+
+def init_app():
+ """Construct core Flask application with embedded Dash app.
+ """
+ logging.basicConfig(
+ format=C.LOG_FORMAT,
+ datefmt=C.LOG_DATE_FORMAT,
+ level=C.LOG_LEVEL
+ )
+
+ app = Flask(__name__, instance_relative_config=False)
+ app.logger.info("Application started.")
+ app.config.from_object("config.Config")
+
+ with app.app_context():
+ # Import parts of our core Flask app.
+ from . import routes
+
+ assets = Environment()
+ assets.init_app(app)
+
+ # Compile static assets.
+ sass_bundle = Bundle(
+ "sass/lux.scss",
+ filters="libsass",
+ output="dist/css/bootstrap.css",
+ depends="**/*.scss",
+ extra={
+ "rel": "stylesheet"
+ }
+ )
+ assets.register("sass_all", sass_bundle)
+ sass_bundle.build()
+
+ if C.TIME_PERIOD is None or C.TIME_PERIOD > C.MAX_TIME_PERIOD:
+ time_period = C.MAX_TIME_PERIOD
+ else:
+ time_period = C.TIME_PERIOD
+
+ data = Data(
+ data_spec_file=C.DATA_SPEC_FILE,
+ ).read_all_data(days=time_period)
+
+ # Import Dash applications.
+ logging.info("\n\nStarting the applications:\n" + "-" * 26 + "\n")
+ if data["statistics"].empty or data["trending"].empty:
+ logging.error(
+ f'"{C.NEWS_TITLE}" application not loaded, no data available.'
+ )
+ logging.error(
+ f'"{C.STATS_TITLE}" application not loaded, no data available.'
+ )
+ else:
+ logging.info(C.NEWS_TITLE)
+ from .news.news import init_news
+ app = init_news(
+ app,
+ data_stats=data["statistics"],
+ data_trending=data["trending"]
+ )
+
+ logging.info(C.STATS_TITLE)
+ from .stats.stats import init_stats
+ app = init_stats(
+ app,
+ data_stats=data["statistics"],
+ data_trending=data["trending"]
+ )
+
+ if data["trending"].empty:
+ logging.error(
+ f'"{C.TREND_TITLE}" application not loaded, no data available.'
+ )
+ else:
+ logging.info(C.TREND_TITLE)
+ from .trending.trending import init_trending
+ app = init_trending(app, data_trending=data["trending"])
+
+ if data["iterative"].empty:
+ logging.error(
+ f'"{C.REPORT_TITLE}" application not loaded, no data available.'
+ )
+ logging.error(
+ f'"{C.COMP_TITLE}" application not loaded, no data available.'
+ )
+ else:
+ logging.info(C.REPORT_TITLE)
+ from .report.report import init_report
+ app = init_report(app, data_iterative=data["iterative"])
+
+ logging.info(C.COMP_TITLE)
+ from .comparisons.comparisons import init_comparisons
+ app = init_comparisons(app, data_iterative=data["iterative"])
+
+ if data["coverage"].empty:
+ logging.error((
+ f'"{C.COVERAGE_TITLE}" application not loaded, '
+ 'no data available.'
+ ))
+ else:
+ logging.info(C.COVERAGE_TITLE)
+ from .coverage.coverage import init_coverage
+ app = init_coverage(app, data_coverage=data["coverage"])
+
+ if all((data["trending"].empty, data["iterative"].empty,
+ data["coverage"].empty)):
+ logging.error((
+ f'"{C.SEARCH_TITLE}" application not loaded, '
+ 'no data available.'
+ ))
+ else:
+ logging.info(C.SEARCH_TITLE)
+ from .search.search import init_search
+ app = init_search(app, data)
+
+ return app
+
+
+app = init_app()
diff --git a/csit.infra.dash/app/cdash/comparisons/__init__.py b/csit.infra.dash/app/cdash/comparisons/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/comparisons/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/comparisons/comparisons.py b/csit.infra.dash/app/cdash/comparisons/comparisons.py
new file mode 100644
index 0000000000..f2cda8138b
--- /dev/null
+++ b/csit.infra.dash/app/cdash/comparisons/comparisons.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Instantiate the Report Dash application.
+"""
+
+import dash
+import pandas as pd
+
+from ..utils.constants import Constants as C
+from .layout import Layout
+
+
+def init_comparisons(
+ server,
+ data_iterative: pd.DataFrame
+ ) -> dash.Dash:
+ """Create a Plotly Dash dashboard.
+
+ :param server: Flask server.
+ :type server: Flask
+ :returns: Dash app server.
+ :rtype: Dash
+ """
+
+ dash_app = dash.Dash(
+ server=server,
+ routes_pathname_prefix=C.COMP_ROUTES_PATHNAME_PREFIX,
+ external_stylesheets=C.EXTERNAL_STYLESHEETS,
+ title=C.COMP_TITLE
+ )
+
+ layout = Layout(
+ app=dash_app,
+ data_iterative=data_iterative,
+ html_layout_file=C.HTML_LAYOUT_FILE,
+ graph_layout_file=C.REPORT_GRAPH_LAYOUT_FILE,
+ tooltip_file=C.TOOLTIP_FILE
+ )
+ dash_app.index_string = layout.html_layout
+ dash_app.layout = layout.add_content()
+
+ return dash_app.server
diff --git a/csit.infra.dash/app/cdash/comparisons/layout.py b/csit.infra.dash/app/cdash/comparisons/layout.py
new file mode 100644
index 0000000000..57a1c2a1b4
--- /dev/null
+++ b/csit.infra.dash/app/cdash/comparisons/layout.py
@@ -0,0 +1,1342 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Plotly Dash HTML layout override.
+"""
+
+
+import logging
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from flask import Flask
+from dash import dcc, html, dash_table, callback_context, no_update, ALL
+from dash import Input, Output, State
+from dash.exceptions import PreventUpdate
+from dash.dash_table.Format import Format, Scheme
+from ast import literal_eval
+from yaml import load, FullLoader, YAMLError
+from copy import deepcopy
+
+from ..utils.constants import Constants as C
+from ..utils.control_panel import ControlPanel
+from ..utils.trigger import Trigger
+from ..utils.url_processing import url_decode
+from ..utils.utils import generate_options, gen_new_url, navbar_report, \
+ filter_table_data, sort_table_data, show_iterative_graph_data, show_tooltip
+from .tables import comparison_table
+from ..report.graphs import graph_iterative
+
+
+# Control panel partameters and their default values.
+CP_PARAMS = {
+ "dut-val": str(),
+ "dutver-opt": list(),
+ "dutver-dis": True,
+ "dutver-val": str(),
+ "infra-opt": list(),
+ "infra-dis": True,
+ "infra-val": str(),
+ "core-opt": list(),
+ "core-val": list(),
+ "frmsize-opt": list(),
+ "frmsize-val": list(),
+ "ttype-opt": list(),
+ "ttype-val": list(),
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str(),
+ "normalize-val": list(),
+ "outliers-val": list()
+}
+
+# List of comparable parameters.
+CMP_PARAMS = {
+ "dutver": "Release and Version",
+ "infra": "Infrastructure",
+ "frmsize": "Frame Size",
+ "core": "Number of Cores",
+ "ttype": "Measurement"
+}
+
+
+class Layout:
+ """The layout of the dash app and the callbacks.
+ """
+
+ def __init__(
+ self,
+ app: Flask,
+ data_iterative: pd.DataFrame,
+ html_layout_file: str,
+ graph_layout_file: str,
+ tooltip_file: str
+ ) -> None:
+ """Initialization:
+ - save the input parameters,
+ - prepare data for the control panel,
+ - read HTML layout file,
+ - read graph layout file,
+ - read tooltips from the tooltip file.
+
+ :param app: Flask application running the dash application.
+ :param data_iterative: Iterative data to be used in comparison tables.
+ :param html_layout_file: Path and name of the file specifying the HTML
+ layout of the dash application.
+ :param tooltip_file: Path and name of the yaml file specifying the
+ tooltips.
+ :param graph_layout_file: Path and name of the file with layout of
+ plot.ly graphs.
+ :type app: Flask
+ :type data_iterative: pandas.DataFrame
+ :type html_layout_file: str
+ :type graph_layout_file: str
+ :type tooltip_file: str
+ """
+
+ # Inputs
+ self._app = app
+ self._data = data_iterative
+ self._html_layout_file = html_layout_file
+ self._graph_layout_file = graph_layout_file
+ self._tooltip_file = tooltip_file
+
+ # Get structure of tests:
+ tbs = dict()
+ cols = [
+ "job", "test_id", "test_type", "dut_type", "dut_version", "tg_type",
+ "release", "passed"
+ ]
+ for _, row in self._data[cols].drop_duplicates().iterrows():
+ lst_job = row["job"].split("-")
+ dut = lst_job[1]
+ dver = f"{row['release']}-{row['dut_version']}"
+ tbed = "-".join(lst_job[-2:])
+ lst_test_id = row["test_id"].split(".")
+
+ suite = lst_test_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
+ replace("2n-", "")
+ test = lst_test_id[-1]
+ nic = suite.split("-")[0]
+ for driver in C.DRIVERS:
+ if driver in test:
+ drv = driver.replace("-", "_")
+ test = test.replace(f"{driver}-", "")
+ break
+ else:
+ drv = "dpdk"
+ infra = "-".join((tbed, nic, drv))
+ lst_test = test.split("-")
+ fsize = lst_test[0]
+ core = lst_test[1] if lst_test[1] else "8C"
+
+ if tbs.get(dut, None) is None:
+ tbs[dut] = dict()
+ if tbs[dut].get(dver, None) is None:
+ tbs[dut][dver] = dict()
+ if tbs[dut][dver].get(infra, None) is None:
+ tbs[dut][dver][infra] = dict()
+ tbs[dut][dver][infra]["core"] = list()
+ tbs[dut][dver][infra]["fsize"] = list()
+ tbs[dut][dver][infra]["ttype"] = list()
+ if core.upper() not in tbs[dut][dver][infra]["core"]:
+ tbs[dut][dver][infra]["core"].append(core.upper())
+ if fsize.upper() not in tbs[dut][dver][infra]["fsize"]:
+ tbs[dut][dver][infra]["fsize"].append(fsize.upper())
+ if row["test_type"] == "mrr":
+ if "MRR" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].append("MRR")
+ elif row["test_type"] == "ndrpdr":
+ if "NDR" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].extend(
+ ("NDR", "PDR", "Latency")
+ )
+ elif row["test_type"] == "hoststack" and \
+ row["tg_type"] in ("iperf", "vpp"):
+ if "BPS" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].append("BPS")
+ elif row["test_type"] == "hoststack" and row["tg_type"] == "ab":
+ if "CPS" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].extend(("CPS", "RPS", ))
+ elif row["test_type"] == "soak":
+ if "SOAK" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].append("SOAK")
+ self._tbs = tbs
+
+ # Read from files:
+ self._html_layout = str()
+ try:
+ with open(self._html_layout_file, "r") as file_read:
+ self._html_layout = file_read.read()
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._html_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._graph_layout_file, "r") as file_read:
+ self._graph_layout = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._graph_layout_file}\n"
+ f"{err}"
+ )
+ except YAMLError as err:
+ raise RuntimeError(
+ f"An error occurred while parsing the specification file "
+ f"{self._graph_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._tooltip_file, "r") as file_read:
+ self._tooltips = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ logging.warning(
+ f"Not possible to open the file {self._tooltip_file}\n{err}"
+ )
+ except YAMLError as err:
+ logging.warning(
+ f"An error occurred while parsing the specification file "
+ f"{self._tooltip_file}\n{err}"
+ )
+
+ # Callbacks:
+ if self._app is not None and hasattr(self, "callbacks"):
+ self.callbacks(self._app)
+
+ @property
+ def html_layout(self):
+ return self._html_layout
+
+ def add_content(self):
+ """Top level method which generated the web page.
+
+ It generates:
+ - Store for user input data,
+ - Navigation bar,
+ - Main area with control panel and ploting area.
+
+ If no HTML layout is provided, an error message is displayed instead.
+
+ :returns: The HTML div with the whole page.
+ :rtype: html.Div
+ """
+
+ if self.html_layout and self._tbs:
+ return html.Div(
+ id="div-main",
+ className="small",
+ children=[
+ dbc.Row(
+ id="row-navbar",
+ class_name="g-0",
+ children=[navbar_report((False, True, False, False)), ]
+ ),
+ dbc.Row(
+ id="row-main",
+ class_name="g-0",
+ children=[
+ dcc.Store(id="store-control-panel"),
+ dcc.Store(id="store-selected"),
+ dcc.Store(id="store-table-data"),
+ dcc.Store(id="store-filtered-table-data"),
+ dcc.Location(id="url", refresh=False),
+ self._add_ctrl_col(),
+ self._add_plotting_col()
+ ]
+ ),
+ dbc.Spinner(
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-details",
+ title="Test Details",
+ placement="end",
+ is_open=False,
+ children=[]
+ ),
+ delay_show=C.SPINNER_DELAY
+ ),
+ dbc.Spinner(
+ dbc.Offcanvas(
+ class_name="w-50",
+ id="offcanvas-metadata",
+ title="Detailed Information",
+ placement="end",
+ is_open=False,
+ children=[
+ dbc.Row(id="metadata-tput-lat"),
+ dbc.Row(id="metadata-hdrh-graph")
+ ]
+ ),
+ delay_show=C.SPINNER_DELAY
+ ),
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-documentation",
+ title="Documentation",
+ placement="end",
+ is_open=False,
+ children=html.Iframe(
+ src=C.URL_DOC_REL_NOTES,
+ width="100%",
+ height="100%"
+ )
+ )
+ ]
+ )
+ else:
+ return html.Div(
+ id="div-main-error",
+ children=[
+ dbc.Alert(
+ [
+ "An Error Occured"
+ ],
+ color="danger"
+ )
+ ]
+ )
+
+ def _add_ctrl_col(self) -> dbc.Col:
+ """Add column with controls. It is placed on the left side.
+
+ :returns: Column with the control panel.
+ :rtype: dbc.Col
+ """
+ return dbc.Col([
+ html.Div(
+ children=self._add_ctrl_panel(),
+ className="sticky-top"
+ )
+ ])
+
+ def _add_plotting_col(self) -> dbc.Col:
+ """Add column with plots. It is placed on the right side.
+
+ :returns: Column with plots.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(
+ id="col-plotting-area",
+ children=[
+ dbc.Spinner(
+ children=[
+ dbc.Row(
+ id="plotting-area",
+ class_name="g-0 p-0",
+ children=[
+ C.PLACEHOLDER
+ ]
+ )
+ ]
+ )
+ ],
+ width=9
+ )
+
+ def _add_ctrl_panel(self) -> list:
+ """Add control panel.
+
+ :returns: Control panel.
+ :rtype: list
+ """
+
+ reference = [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ show_tooltip(self._tooltips, "help-dut", "DUT")
+ ),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dut"},
+ placeholder="Select a Device under Test...",
+ options=sorted(
+ [
+ {"label": k, "value": k} \
+ for k in self._tbs.keys()
+ ],
+ key=lambda d: d["label"]
+ )
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-csit-dut",
+ "CSIT and DUT Version"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dutver"},
+ placeholder="Select a CSIT and DUT Version...")
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-infra",
+ "Infra"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "infra"},
+ placeholder=\
+ "Select a Physical Test Bed Topology..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-framesize",
+ "Frame Size"
+ )),
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "frmsize"},
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-cores",
+ "Number of Cores"
+ )),
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "core"},
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-measurement",
+ "Measurement"
+ )),
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "ttype"},
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ )
+ ]
+
+ compare = [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-cmp-parameter",
+ "Parameter"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "cmpprm"},
+ placeholder="Select a Parameter..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-cmp-value",
+ "Value"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "cmpval"},
+ placeholder="Select a Value..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ )
+ ]
+
+ processing = [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ children = [
+ dbc.Checklist(
+ id="normalize",
+ options=[{
+ "value": "normalize",
+ "label": "Normalize to 2GHz CPU frequency"
+ }],
+ value=[],
+ inline=True,
+ class_name="ms-2"
+ ),
+ dbc.Checklist(
+ id="outliers",
+ options=[{
+ "value": "outliers",
+ "label": "Remove Extreme Outliers"
+ }],
+ value=[],
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ )
+ ]
+
+ return [
+ dbc.Row(
+ dbc.Card(
+ [
+ dbc.CardHeader(
+ html.H5("Reference Value")
+ ),
+ dbc.CardBody(
+ children=reference,
+ class_name="g-0 p-0"
+ )
+ ],
+ color="secondary",
+ outline=True
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.Card(
+ [
+ dbc.CardHeader(
+ html.H5("Compared Value")
+ ),
+ dbc.CardBody(
+ children=compare,
+ class_name="g-0 p-0"
+ )
+ ],
+ color="secondary",
+ outline=True
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.Card(
+ [
+ dbc.CardHeader(
+ html.H5("Data Manipulations")
+ ),
+ dbc.CardBody(
+ children=processing,
+ class_name="g-0 p-0"
+ )
+ ],
+ color="secondary",
+ outline=True
+ ),
+ class_name="g-0 p-1"
+ )
+ ]
+
+ @staticmethod
+ def _get_plotting_area(
+ title: str,
+ table: pd.DataFrame,
+ url: str
+ ) -> list:
+ """Generate the plotting area with all its content.
+
+ :param title: The title of the comparison table.
+ :param table: Comparison table to be displayed.
+ :param url: URL to be displayed in the modal window.
+ :type title: str
+ :type table: pandas.DataFrame
+ :type url: str
+ :returns: List of rows with elements to be displayed in the plotting
+ area.
+ :rtype: list
+ """
+
+ if table.empty:
+ return dbc.Row(
+ dbc.Col(
+ children=dbc.Alert(
+ "No data for comparison.",
+ color="danger"
+ ),
+ class_name="g-0 p-1",
+ ),
+ class_name="g-0 p-0"
+ )
+
+ cols = list()
+ for idx, col in enumerate(table.columns):
+ if idx == 0:
+ cols.append({
+ "name": ["", col],
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "text"
+ })
+ else:
+ l_col = col.rsplit(" ", 2)
+ cols.append({
+ "name": [l_col[0], " ".join(l_col[-2:])],
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "numeric",
+ "format": Format(precision=2, scheme=Scheme.fixed)
+ })
+
+ return [
+ dbc.Row(
+ children=html.H5(title),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ children=[
+ dbc.Col(
+ children=dash_table.DataTable(
+ id={"type": "table", "index": "comparison"},
+ columns=cols,
+ data=table.to_dict("records"),
+ merge_duplicate_headers=True,
+ editable=False,
+ filter_action="custom",
+ filter_query="",
+ sort_action="custom",
+ sort_mode="multi",
+ selected_columns=[],
+ selected_rows=[],
+ page_action="none",
+ style_cell={"textAlign": "right"},
+ style_cell_conditional=[{
+ "if": {"column_id": "Test Name"},
+ "textAlign": "left"
+ }]
+ ),
+ class_name="g-0 p-1"
+ )
+ ],
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ [
+ dbc.Col([html.Div(
+ [
+ dbc.Button(
+ id="plot-btn-url",
+ children="Show URL",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(dbc.ModalTitle("URL")),
+ dbc.ModalBody(url)
+ ],
+ id="plot-mod-url",
+ size="xl",
+ is_open=False,
+ scrollable=True
+ ),
+ dbc.Button(
+ id="plot-btn-download",
+ children="Download Table",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dcc.Download(id="download-iterative-data"),
+ dbc.Button(
+ id="plot-btn-download-raw",
+ children="Download Raw Data",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dcc.Download(id="download-raw-data")
+ ],
+ className=\
+ "d-grid gap-0 d-md-flex justify-content-md-end"
+ )])
+ ],
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ children=C.PLACEHOLDER,
+ class_name="g-0 p-1"
+ )
+ ]
+
+ def callbacks(self, app):
+ """Callbacks for the whole application.
+
+ :param app: The application.
+ :type app: Flask
+ """
+
+ @app.callback(
+ [
+ Output("store-control-panel", "data"),
+ Output("store-selected", "data"),
+ Output("store-table-data", "data"),
+ Output("store-filtered-table-data", "data"),
+ Output("plotting-area", "children"),
+ Output({"type": "table", "index": ALL}, "data"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "value"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
+ Output({"type": "ctrl-dd", "index": "infra"}, "options"),
+ Output({"type": "ctrl-dd", "index": "infra"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "infra"}, "value"),
+ Output({"type": "ctrl-cl", "index": "core"}, "options"),
+ Output({"type": "ctrl-cl", "index": "core"}, "value"),
+ Output({"type": "ctrl-cl", "index": "frmsize"}, "options"),
+ Output({"type": "ctrl-cl", "index": "frmsize"}, "value"),
+ Output({"type": "ctrl-cl", "index": "ttype"}, "options"),
+ Output({"type": "ctrl-cl", "index": "ttype"}, "value"),
+ Output({"type": "ctrl-dd", "index": "cmpprm"}, "options"),
+ Output({"type": "ctrl-dd", "index": "cmpprm"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "cmpprm"}, "value"),
+ Output({"type": "ctrl-dd", "index": "cmpval"}, "options"),
+ Output({"type": "ctrl-dd", "index": "cmpval"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "cmpval"}, "value"),
+ Output("normalize", "value"),
+ Output("outliers", "value")
+ ],
+ [
+ State("store-control-panel", "data"),
+ State("store-selected", "data"),
+ State("store-table-data", "data"),
+ State("store-filtered-table-data", "data"),
+ State({"type": "table", "index": ALL}, "data")
+ ],
+ [
+ Input("url", "href"),
+ Input("normalize", "value"),
+ Input("outliers", "value"),
+ Input({"type": "table", "index": ALL}, "filter_query"),
+ Input({"type": "table", "index": ALL}, "sort_by"),
+ Input({"type": "ctrl-dd", "index": ALL}, "value"),
+ Input({"type": "ctrl-cl", "index": ALL}, "value"),
+ Input({"type": "ctrl-btn", "index": ALL}, "n_clicks")
+ ]
+ )
+ def _update_application(
+ control_panel: dict,
+ selected: dict,
+ store_table_data: list,
+ filtered_data: list,
+ table_data: list,
+ href: str,
+ normalize: list,
+ outliers: bool,
+ *_
+ ) -> tuple:
+ """Update the application when the event is detected.
+ """
+
+ ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
+
+ if selected is None:
+ selected = {
+ "reference": {
+ "set": False,
+ },
+ "compare": {
+ "set": False,
+ }
+ }
+
+ # Parse the url:
+ parsed_url = url_decode(href)
+ if parsed_url:
+ url_params = parsed_url["params"]
+ else:
+ url_params = None
+
+ on_draw = False
+ plotting_area = no_update
+
+ trigger = Trigger(callback_context.triggered)
+ if trigger.type == "url" and url_params:
+ process_url = False
+ try:
+ selected = literal_eval(url_params["selected"][0])
+ r_sel = selected["reference"]["selection"]
+ c_sel = selected["compare"]
+ normalize = literal_eval(url_params["norm"][0])
+ try: # Necessary for backward compatibility
+ outliers = literal_eval(url_params["outliers"][0])
+ except (KeyError, IndexError, AttributeError):
+ outliers = list()
+ process_url = bool(
+ (selected["reference"]["set"] == True) and
+ (c_sel["set"] == True)
+ )
+ except (KeyError, IndexError, AttributeError):
+ pass
+ if process_url:
+ ctrl_panel.set({
+ "dut-val": r_sel["dut"],
+ "dutver-opt": generate_options(
+ self._tbs[r_sel["dut"]].keys()
+ ),
+ "dutver-dis": False,
+ "dutver-val": r_sel["dutver"],
+ "infra-opt": generate_options(
+ self._tbs[r_sel["dut"]][r_sel["dutver"]].keys()
+ ),
+ "infra-dis": False,
+ "infra-val": r_sel["infra"],
+ "core-opt": generate_options(
+ self._tbs[r_sel["dut"]][r_sel["dutver"]]\
+ [r_sel["infra"]]["core"]
+ ),
+ "core-val": r_sel["core"],
+ "frmsize-opt": generate_options(
+ self._tbs[r_sel["dut"]][r_sel["dutver"]]\
+ [r_sel["infra"]]["fsize"]
+ ),
+ "frmsize-val": r_sel["frmsize"],
+ "ttype-opt": generate_options(
+ self._tbs[r_sel["dut"]][r_sel["dutver"]]\
+ [r_sel["infra"]]["ttype"]
+ ),
+ "ttype-val": r_sel["ttype"],
+ "normalize-val": normalize,
+ "outliers-val": outliers
+ })
+ opts = list()
+ for itm, label in CMP_PARAMS.items():
+ if len(ctrl_panel.get(f"{itm}-opt")) > 1:
+ opts.append({"label": label, "value": itm})
+ ctrl_panel.set({
+ "cmp-par-opt": opts,
+ "cmp-par-dis": False,
+ "cmp-par-val": c_sel["parameter"]
+ })
+ opts = list()
+ for itm in ctrl_panel.get(f"{c_sel['parameter']}-opt"):
+ set_val = ctrl_panel.get(f"{c_sel['parameter']}-val")
+ if isinstance(set_val, list):
+ if itm["value"] not in set_val:
+ opts.append(itm)
+ else:
+ if itm["value"] != set_val:
+ opts.append(itm)
+ ctrl_panel.set({
+ "cmp-val-opt": opts,
+ "cmp-val-dis": False,
+ "cmp-val-val": c_sel["value"]
+ })
+ on_draw = True
+ elif trigger.type == "normalize":
+ ctrl_panel.set({"normalize-val": normalize})
+ on_draw = True
+ elif trigger.type == "outliers":
+ ctrl_panel.set({"outliers-val": outliers})
+ on_draw = True
+ elif trigger.type == "ctrl-dd":
+ if trigger.idx == "dut":
+ try:
+ opts = generate_options(self._tbs[trigger.value].keys())
+ disabled = False
+ except KeyError:
+ opts = list()
+ disabled = True
+ ctrl_panel.set({
+ "dut-val": trigger.value,
+ "dutver-opt": opts,
+ "dutver-dis": disabled,
+ "dutver-val": str(),
+ "infra-opt": list(),
+ "infra-dis": True,
+ "infra-val": str(),
+ "core-opt": list(),
+ "core-val": list(),
+ "frmsize-opt": list(),
+ "frmsize-val": list(),
+ "ttype-opt": list(),
+ "ttype-val": list(),
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ elif trigger.idx == "dutver":
+ try:
+ dut = ctrl_panel.get("dut-val")
+ dver = self._tbs[dut][trigger.value]
+ opts = generate_options(dver.keys())
+ disabled = False
+ except KeyError:
+ opts = list()
+ disabled = True
+ ctrl_panel.set({
+ "dutver-val": trigger.value,
+ "infra-opt": opts,
+ "infra-dis": disabled,
+ "infra-val": str(),
+ "core-opt": list(),
+ "core-val": list(),
+ "frmsize-opt": list(),
+ "frmsize-val": list(),
+ "ttype-opt": list(),
+ "ttype-val": list(),
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ elif trigger.idx == "infra":
+ dut = ctrl_panel.get("dut-val")
+ dver = ctrl_panel.get("dutver-val")
+ if all((dut, dver, trigger.value, )):
+ driver = self._tbs[dut][dver][trigger.value]
+ ctrl_panel.set({
+ "infra-val": trigger.value,
+ "core-opt": generate_options(driver["core"]),
+ "core-val": list(),
+ "frmsize-opt": generate_options(driver["fsize"]),
+ "frmsize-val": list(),
+ "ttype-opt": generate_options(driver["ttype"]),
+ "ttype-val": list(),
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ elif trigger.idx == "cmpprm":
+ value = trigger.value
+ opts = list()
+ for itm in ctrl_panel.get(f"{value}-opt"):
+ set_val = ctrl_panel.get(f"{value}-val")
+ if isinstance(set_val, list):
+ if itm["value"] == "Latency":
+ continue
+ if itm["value"] not in set_val:
+ opts.append(itm)
+ else:
+ if itm["value"] != set_val:
+ opts.append(itm)
+ ctrl_panel.set({
+ "cmp-par-val": value,
+ "cmp-val-opt": opts,
+ "cmp-val-dis": False,
+ "cmp-val-val": str()
+ })
+ elif trigger.idx == "cmpval":
+ ctrl_panel.set({"cmp-val-val": trigger.value})
+ selected["reference"] = {
+ "set": True,
+ "selection": {
+ "dut": ctrl_panel.get("dut-val"),
+ "dutver": ctrl_panel.get("dutver-val"),
+ "infra": ctrl_panel.get("infra-val"),
+ "core": ctrl_panel.get("core-val"),
+ "frmsize": ctrl_panel.get("frmsize-val"),
+ "ttype": ctrl_panel.get("ttype-val")
+ }
+ }
+ selected["compare"] = {
+ "set": True,
+ "parameter": ctrl_panel.get("cmp-par-val"),
+ "value": trigger.value
+ }
+ on_draw = True
+ elif trigger.type == "ctrl-cl":
+ ctrl_panel.set({f"{trigger.idx}-val": trigger.value})
+ if all((ctrl_panel.get("core-val"),
+ ctrl_panel.get("frmsize-val"),
+ ctrl_panel.get("ttype-val"), )):
+ if "Latency" in ctrl_panel.get("ttype-val"):
+ ctrl_panel.set({"ttype-val": ["Latency", ]})
+ opts = list()
+ for itm, label in CMP_PARAMS.items():
+ if "Latency" in ctrl_panel.get("ttype-val") and \
+ itm == "ttype":
+ continue
+ if len(ctrl_panel.get(f"{itm}-opt")) > 1:
+ if isinstance(ctrl_panel.get(f"{itm}-val"), list):
+ if len(ctrl_panel.get(f"{itm}-opt")) == \
+ len(ctrl_panel.get(f"{itm}-val")):
+ continue
+ opts.append({"label": label, "value": itm})
+ ctrl_panel.set({
+ "cmp-par-opt": opts,
+ "cmp-par-dis": False,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ else:
+ ctrl_panel.set({
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ elif trigger.type == "table" and trigger.idx == "comparison":
+ if trigger.parameter == "filter_query":
+ filtered_data = filter_table_data(
+ store_table_data,
+ trigger.value
+ )
+ elif trigger.parameter == "sort_by":
+ filtered_data = sort_table_data(
+ store_table_data,
+ trigger.value
+ )
+ table_data = [filtered_data, ]
+
+ if all((on_draw, selected["reference"]["set"],
+ selected["compare"]["set"], )):
+ title, table = comparison_table(
+ data=self._data,
+ selected=selected,
+ normalize=normalize,
+ format="html",
+ remove_outliers=outliers
+ )
+ plotting_area = self._get_plotting_area(
+ title=title,
+ table=table,
+ url=gen_new_url(
+ parsed_url,
+ params={
+ "selected": selected,
+ "norm": normalize,
+ "outliers": outliers
+ }
+ )
+ )
+ store_table_data = table.to_dict("records")
+ filtered_data = store_table_data
+ if table_data:
+ table_data = [store_table_data, ]
+
+ ret_val = [
+ ctrl_panel.panel,
+ selected,
+ store_table_data,
+ filtered_data,
+ plotting_area,
+ table_data
+ ]
+ ret_val.extend(ctrl_panel.values)
+ return ret_val
+
+ @app.callback(
+ Output("plot-mod-url", "is_open"),
+ Input("plot-btn-url", "n_clicks"),
+ State("plot-mod-url", "is_open")
+ )
+ def toggle_plot_mod_url(n, is_open):
+ """Toggle the modal window with url.
+ """
+ if n:
+ return not is_open
+ return is_open
+
+ @app.callback(
+ Output("download-iterative-data", "data"),
+ State("store-table-data", "data"),
+ State("store-filtered-table-data", "data"),
+ Input("plot-btn-download", "n_clicks"),
+ prevent_initial_call=True
+ )
+ def _download_comparison_data(
+ table_data: list,
+ filtered_table_data: list,
+ _: int
+ ) -> dict:
+ """Download the data.
+
+ :param table_data: Original unfiltered table data.
+ :param filtered_table_data: Filtered table data.
+ :type table_data: list
+ :type filtered_table_data: list
+ :returns: dict of data frame content (base64 encoded) and meta data
+ used by the Download component.
+ :rtype: dict
+ """
+
+ if not table_data:
+ raise PreventUpdate
+
+ if filtered_table_data:
+ table = pd.DataFrame.from_records(filtered_table_data)
+ else:
+ table = pd.DataFrame.from_records(table_data)
+
+ return dcc.send_data_frame(table.to_csv, C.COMP_DOWNLOAD_FILE_NAME)
+
+ @app.callback(
+ Output("download-raw-data", "data"),
+ State("store-selected", "data"),
+ Input("plot-btn-download-raw", "n_clicks"),
+ prevent_initial_call=True
+ )
+ def _download_raw_comparison_data(selected: dict, _: int) -> dict:
+ """Download the data.
+
+ :param selected: Selected tests.
+ :type selected: dict
+ :returns: dict of data frame content (base64 encoded) and meta data
+ used by the Download component.
+ :rtype: dict
+ """
+
+ if not selected:
+ raise PreventUpdate
+
+ _, table = comparison_table(
+ data=self._data,
+ selected=selected,
+ normalize=False,
+ remove_outliers=False,
+ raw_data=True
+ )
+
+ return dcc.send_data_frame(
+ table.dropna(how="all", axis=1).to_csv,
+ f"raw_{C.COMP_DOWNLOAD_FILE_NAME}"
+ )
+
+ @app.callback(
+ Output("offcanvas-documentation", "is_open"),
+ Input("btn-documentation", "n_clicks"),
+ State("offcanvas-documentation", "is_open")
+ )
+ def toggle_offcanvas_documentation(n_clicks, is_open):
+ if n_clicks:
+ return not is_open
+ return is_open
+
+ @app.callback(
+ Output("offcanvas-details", "is_open"),
+ Output("offcanvas-details", "children"),
+ State("store-selected", "data"),
+ State("store-filtered-table-data", "data"),
+ State("normalize", "value"),
+ State("outliers", "value"),
+ Input({"type": "table", "index": ALL}, "active_cell"),
+ prevent_initial_call=True
+ )
+ def show_test_data(cp_sel, table, normalize, outliers, *_):
+ """Show offcanvas with graphs and tables based on selected test(s).
+ """
+
+ trigger = Trigger(callback_context.triggered)
+ if not all((trigger.value, cp_sel["reference"]["set"], \
+ cp_sel["compare"]["set"])):
+ raise PreventUpdate
+
+ try:
+ test_name = pd.DataFrame.from_records(table).\
+ iloc[[trigger.value["row"]]]["Test Name"].iloc[0]
+ dut = cp_sel["reference"]["selection"]["dut"]
+ rls, dutver = cp_sel["reference"]["selection"]["dutver"].\
+ split("-", 1)
+ phy = cp_sel["reference"]["selection"]["infra"]
+ framesize, core, test_id = test_name.split("-", 2)
+ test, ttype = test_id.rsplit("-", 1)
+ ttype = "pdr" if ttype == "latency" else ttype
+ l_phy = phy.split("-")
+ tb = "-".join(l_phy[:2])
+ nic = l_phy[2]
+ stype = "ndrpdr" if ttype in ("ndr", "pdr") else ttype
+ except(KeyError, IndexError, AttributeError, ValueError):
+ raise PreventUpdate
+
+ df = pd.DataFrame(self._data.loc[(
+ (self._data["dut_type"] == dut) &
+ (self._data["dut_version"] == dutver) &
+ (self._data["release"] == rls)
+ )])
+ df = df[df.job.str.endswith(tb)]
+ df = df[df.test_id.str.contains(
+ f"{nic}.*{test}-{stype}", regex=True
+ )]
+ if df.empty:
+ raise PreventUpdate
+
+ l_test_id = df["test_id"].iloc[0].split(".")
+ area = ".".join(l_test_id[3:-2])
+
+ r_sel = {
+ "id": f"{test}-{ttype}",
+ "rls": rls,
+ "dut": dut,
+ "dutver": dutver,
+ "phy": phy,
+ "area": area,
+ "test": test,
+ "framesize": framesize,
+ "core": core,
+ "testtype": ttype
+ }
+
+ c_sel = deepcopy(r_sel)
+ param = cp_sel["compare"]["parameter"]
+ val = cp_sel["compare"]["value"].lower()
+ if param == "dutver":
+ c_sel["rls"], c_sel["dutver"] = val.split("-", 1)
+ elif param == "ttype":
+ c_sel["id"] = f"{test}-{val}"
+ c_sel["testtype"] = val
+ elif param == "infra":
+ c_sel["phy"] = val
+ else:
+ c_sel[param] = val
+
+ r_sel["id"] = "-".join(
+ (r_sel["phy"], r_sel["framesize"], r_sel["core"], r_sel["id"])
+ )
+ c_sel["id"] = "-".join(
+ (c_sel["phy"], c_sel["framesize"], c_sel["core"], c_sel["id"])
+ )
+ selected = [r_sel, c_sel]
+
+ indexes = ("tput", "bandwidth", "lat")
+ graphs = graph_iterative(
+ self._data,
+ selected,
+ self._graph_layout,
+ bool(normalize),
+ bool(outliers)
+ )
+ cols = list()
+ for graph, idx in zip(graphs, indexes):
+ if graph:
+ cols.append(dbc.Col(dcc.Graph(
+ figure=graph,
+ id={"type": "graph-iter", "index": idx},
+ )))
+ if not cols:
+ cols="No data."
+ ret_val = [
+ dbc.Row(
+ class_name="g-0 p-0",
+ children=dbc.Alert(test, color="info"),
+ ),
+ dbc.Row(class_name="g-0 p-0", children=cols)
+ ]
+
+ return True, ret_val
+
+ @app.callback(
+ Output("metadata-tput-lat", "children"),
+ Output("metadata-hdrh-graph", "children"),
+ Output("offcanvas-metadata", "is_open"),
+ Input({"type": "graph-iter", "index": ALL}, "clickData"),
+ prevent_initial_call=True
+ )
+ def _show_metadata_from_graph(iter_data: dict) -> tuple:
+ """Generates the data for the offcanvas displayed when a particular
+ point in a graph is clicked on.
+ """
+
+ trigger = Trigger(callback_context.triggered)
+ if not trigger.value:
+ raise PreventUpdate
+
+ if trigger.type == "graph-iter":
+ return show_iterative_graph_data(
+ trigger, iter_data, self._graph_layout)
+ else:
+ raise PreventUpdate
diff --git a/csit.infra.dash/app/cdash/comparisons/tables.py b/csit.infra.dash/app/cdash/comparisons/tables.py
new file mode 100644
index 0000000000..0e32f38b6c
--- /dev/null
+++ b/csit.infra.dash/app/cdash/comparisons/tables.py
@@ -0,0 +1,348 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""The comparison tables.
+"""
+
+import pandas as pd
+
+from numpy import mean, std, percentile
+from copy import deepcopy
+
+from ..utils.constants import Constants as C
+from ..utils.utils import relative_change_stdev
+
+
+def select_comp_data(
+ data: pd.DataFrame,
+ selected: dict,
+ normalize: bool=False,
+ remove_outliers: bool=False,
+ raw_data: bool=False
+ ) -> pd.DataFrame:
+ """Select data for a comparison table.
+
+ :param data: Data to be filtered for the comparison table.
+ :param selected: A dictionary with parameters and their values selected by
+ the user.
+ :param normalize: If True, the data is normalized to CPU frequency
+ Constants.NORM_FREQUENCY.
+ :param remove_outliers: If True the outliers are removed before
+ generating the table.
+ :param raw_data: If True, returns data as it is in parquets without any
+ processing. It is used for "download raw data" feature.
+ :type data: pandas.DataFrame
+ :type selected: dict
+ :type normalize: bool
+ :type remove_outliers: bool
+ :type raw_data: bool
+ :returns: A data frame with selected data.
+ :rtype: pandas.DataFrame
+ """
+
+ def _calculate_statistics(
+ data_in: pd.DataFrame,
+ ttype: str,
+ drv: str,
+ norm_factor: float,
+ remove_outliers: bool=False
+ ) -> pd.DataFrame:
+ """Calculates mean value and standard deviation for provided data.
+
+ :param data_in: Input data for calculations.
+ :param ttype: The test type.
+ :param drv: The driver.
+ :param norm_factor: The data normalization factor.
+ :param remove_outliers: If True the outliers are removed before
+ generating the table.
+ :type data_in: pandas.DataFrame
+ :type ttype: str
+ :type drv: str
+ :type norm_factor: float
+ :type remove_outliers: bool
+ :returns: A pandas dataframe with: test name, mean value, standard
+ deviation and unit.
+ :rtype: pandas.DataFrame
+ """
+ d_data = {
+ "name": list(),
+ "mean": list(),
+ "stdev": list(),
+ "unit": list()
+ }
+ for itm in data_in["test_id"].unique().tolist():
+ itm_lst = itm.split(".")
+ test = itm_lst[-1].rsplit("-", 1)[0]
+ if "hoststack" in itm:
+ test_type = f"hoststack-{ttype}"
+ else:
+ test_type = ttype
+ df = data_in.loc[(data_in["test_id"] == itm)]
+ l_df = df[C.VALUE_ITER[test_type]].to_list()
+ if len(l_df) and isinstance(l_df[0], list):
+ tmp_df = list()
+ for l_itm in l_df:
+ tmp_df.extend(l_itm)
+ l_df = tmp_df
+
+ try:
+ if remove_outliers:
+ q1 = percentile(l_df, 25, method=C.COMP_PERCENTILE_METHOD)
+ q3 = percentile(l_df, 75, method=C.COMP_PERCENTILE_METHOD)
+ irq = q3 - q1
+ lif = q1 - C.COMP_OUTLIER_TYPE * irq
+ uif = q3 + C.COMP_OUTLIER_TYPE * irq
+ l_df = [i for i in l_df if i >= lif and i <= uif]
+ mean_val = mean(l_df)
+ std_val = std(l_df)
+ except (TypeError, ValueError):
+ continue
+ d_data["name"].append(f"{test.replace(f'{drv}-', '')}-{ttype}")
+ d_data["mean"].append(int(mean_val * norm_factor))
+ d_data["stdev"].append(int(std_val * norm_factor))
+ d_data["unit"].append(df[C.UNIT[test_type]].to_list()[0])
+ return pd.DataFrame(d_data)
+
+ lst_df = list()
+ for itm in selected:
+ if itm["ttype"] in ("NDR", "PDR", "Latency"):
+ test_type = "ndrpdr"
+ elif itm["ttype"] in ("CPS", "RPS", "BPS"):
+ test_type = "hoststack"
+ else:
+ test_type = itm["ttype"].lower()
+
+ dutver = itm["dutver"].split("-", 1) # 0 -> release, 1 -> dut version
+ tmp_df = pd.DataFrame(data.loc[(
+ (data["passed"] == True) &
+ (data["dut_type"] == itm["dut"]) &
+ (data["dut_version"] == dutver[1]) &
+ (data["test_type"] == test_type) &
+ (data["release"] == dutver[0])
+ )])
+
+ drv = "" if itm["driver"] == "dpdk" else itm["driver"].replace("_", "-")
+ core = str() if itm["dut"] == "trex" else itm["core"].lower()
+ ttype = "ndrpdr" if itm["ttype"] in ("NDR", "PDR", "Latency") \
+ else itm["ttype"].lower()
+ tmp_df = tmp_df[
+ (tmp_df.job.str.endswith(itm["tbed"])) &
+ (tmp_df.test_id.str.contains(
+ (
+ f"^.*[.|-]{itm['nic']}.*{itm['frmsize'].lower()}-"
+ f"{core}-{drv}.*-{ttype}$"
+ ),
+ regex=True
+ ))
+ ]
+ if itm["driver"] == "dpdk":
+ for drv in C.DRIVERS:
+ tmp_df.drop(
+ tmp_df[tmp_df.test_id.str.contains(f"-{drv}-")].index,
+ inplace=True
+ )
+
+ # Change the data type from ndrpdr to one of ("NDR", "PDR", "Latency")
+ if test_type == "ndrpdr":
+ tmp_df = tmp_df.assign(test_type=itm["ttype"].lower())
+
+ if not tmp_df.empty:
+ if normalize:
+ if itm["ttype"] == "Latency":
+ norm_factor = C.FREQUENCY[itm["tbed"]] / C.NORM_FREQUENCY
+ else:
+ norm_factor = C.NORM_FREQUENCY / C.FREQUENCY[itm["tbed"]]
+ else:
+ norm_factor = 1.0
+ if not raw_data:
+ tmp_df = _calculate_statistics(
+ tmp_df,
+ itm["ttype"].lower(),
+ itm["driver"],
+ norm_factor,
+ remove_outliers=remove_outliers
+ )
+
+ lst_df.append(tmp_df)
+
+ if len(lst_df) == 1:
+ df = lst_df[0]
+ elif len(lst_df) > 1:
+ df = pd.concat(
+ lst_df,
+ ignore_index=True,
+ copy=False
+ )
+ else:
+ df = pd.DataFrame()
+
+ return df
+
+
+def comparison_table(
+ data: pd.DataFrame,
+ selected: dict,
+ normalize: bool,
+ format: str="html",
+ remove_outliers: bool=False,
+ raw_data: bool=False
+ ) -> tuple:
+ """Generate a comparison table.
+
+ :param data: Iterative data for the comparison table.
+ :param selected: A dictionary with parameters and their values selected by
+ the user.
+ :param normalize: If True, the data is normalized to CPU frequency
+ Constants.NORM_FREQUENCY.
+ :param format: The output format of the table:
+ - html: To be displayed on html page, the values are shown in millions
+ of the unit.
+ - csv: To be downloaded as a CSV file the values are stored in base
+ units.
+ :param remove_outliers: If True the outliers are removed before
+ generating the table.
+ :param raw_data: If True, returns data as it is in parquets without any
+ processing. It is used for "download raw data" feature.
+ :type data: pandas.DataFrame
+ :type selected: dict
+ :type normalize: bool
+ :type format: str
+ :type remove_outliers: bool
+ :type raw_data: bool
+ :returns: A tuple with the tabe title and the comparison table.
+ :rtype: tuple[str, pandas.DataFrame]
+ """
+
+ def _create_selection(sel: dict) -> list:
+ """Transform the complex dictionary with user selection to list
+ of simple items.
+
+ :param sel: A complex dictionary with user selection.
+ :type sel: dict
+ :returns: A list of simple items.
+ :rtype: list
+ """
+ l_infra = sel["infra"].split("-")
+ selection = list()
+ for core in sel["core"]:
+ for fsize in sel["frmsize"]:
+ for ttype in sel["ttype"]:
+ selection.append({
+ "dut": sel["dut"],
+ "dutver": sel["dutver"],
+ "tbed": f"{l_infra[0]}-{l_infra[1]}",
+ "nic": l_infra[2],
+ "driver": l_infra[-1].replace("_", "-"),
+ "core": core,
+ "frmsize": fsize,
+ "ttype": ttype
+ })
+ return selection
+
+ # Select reference data
+ r_sel = deepcopy(selected["reference"]["selection"])
+ r_selection = _create_selection(r_sel)
+ r_data = select_comp_data(
+ data, r_selection, normalize, remove_outliers, raw_data
+ )
+
+ # Select compare data
+ c_sel = deepcopy(selected["reference"]["selection"])
+ c_params = selected["compare"]
+ if c_params["parameter"] in ("core", "frmsize", "ttype"):
+ c_sel[c_params["parameter"]] = [c_params["value"], ]
+ else:
+ c_sel[c_params["parameter"]] = c_params["value"]
+ c_selection = _create_selection(c_sel)
+ c_data = select_comp_data(
+ data, c_selection, normalize, remove_outliers, raw_data
+ )
+
+ if raw_data:
+ r_data["ref/cmp"] = "reference"
+ c_data["ref/cmp"] = "compare"
+ return str(), pd.concat([r_data, c_data], ignore_index=True, copy=False)
+
+ if r_data.empty or c_data.empty:
+ return str(), pd.DataFrame()
+
+ if format == "html" and "Latency" not in r_sel["ttype"]:
+ unit_factor, s_unit_factor = (1e6, "M")
+ else:
+ unit_factor, s_unit_factor = (1, str())
+
+ # Create Table title and titles of columns with data
+ params = list(r_sel)
+ params.remove(c_params["parameter"])
+ lst_title = list()
+ for param in params:
+ value = r_sel[param]
+ if isinstance(value, list):
+ lst_title.append("|".join(value))
+ else:
+ lst_title.append(value)
+ title = "Comparison for: " + "-".join(lst_title)
+ r_name = r_sel[c_params["parameter"]]
+ if isinstance(r_name, list):
+ r_name = "|".join(r_name)
+ c_name = c_params["value"]
+
+ l_name, l_r_mean, l_r_std, l_c_mean, l_c_std, l_rc_mean, l_rc_std, unit = \
+ list(), list(), list(), list(), list(), list(), list(), set()
+ for _, row in r_data.iterrows():
+ if c_params["parameter"] in ("core", "frmsize", "ttype"):
+ l_cmp = row["name"].split("-")
+ if c_params["parameter"] == "core":
+ c_row = c_data[
+ (c_data.name.str.contains(l_cmp[0])) &
+ (c_data.name.str.contains("-".join(l_cmp[2:])))
+ ]
+ elif c_params["parameter"] == "frmsize":
+ c_row = c_data[c_data.name.str.contains("-".join(l_cmp[1:]))]
+ elif c_params["parameter"] == "ttype":
+ regex = r"^" + f"{'-'.join(l_cmp[:-1])}" + r"-.{3}$"
+ c_row = c_data[c_data.name.str.contains(regex, regex=True)]
+ else:
+ c_row = c_data[c_data["name"] == row["name"]]
+ if not c_row.empty:
+ unit.add(f"{s_unit_factor}{row['unit']}")
+ r_mean = row["mean"]
+ r_std = row["stdev"]
+ c_mean = c_row["mean"].values[0]
+ c_std = c_row["stdev"].values[0]
+ l_name.append(row["name"])
+ l_r_mean.append(r_mean / unit_factor)
+ l_r_std.append(r_std / unit_factor)
+ l_c_mean.append(c_mean / unit_factor)
+ l_c_std.append(c_std / unit_factor)
+ delta, d_stdev = relative_change_stdev(r_mean, c_mean, r_std, c_std)
+ l_rc_mean.append(delta)
+ l_rc_std.append(d_stdev)
+
+ s_unit = "|".join(unit)
+ df_cmp = pd.DataFrame.from_dict({
+ "Test Name": l_name,
+ f"{r_name} Mean [{s_unit}]": l_r_mean,
+ f"{r_name} Stdev [{s_unit}]": l_r_std,
+ f"{c_name} Mean [{s_unit}]": l_c_mean,
+ f"{c_name} Stdev [{s_unit}]": l_c_std,
+ "Relative Change Mean [%]": l_rc_mean,
+ "Relative Change Stdev [%]": l_rc_std
+ })
+ df_cmp.sort_values(
+ by="Relative Change Mean [%]",
+ ascending=False,
+ inplace=True
+ )
+
+ return (title, df_cmp)
diff --git a/csit.infra.dash/app/cdash/coverage/__init__.py b/csit.infra.dash/app/cdash/coverage/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/coverage/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/coverage/coverage.py b/csit.infra.dash/app/cdash/coverage/coverage.py
new file mode 100644
index 0000000000..f62057d59b
--- /dev/null
+++ b/csit.infra.dash/app/cdash/coverage/coverage.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Instantiate the Coverage Dash application.
+"""
+import dash
+import pandas as pd
+
+from ..utils.constants import Constants as C
+from .layout import Layout
+
+
+def init_coverage(
+ server,
+ data_coverage: pd.DataFrame
+ ) -> dash.Dash:
+ """Create a Plotly Dash dashboard.
+
+ :param server: Flask server.
+ :type server: Flask
+ :returns: Dash app server.
+ :rtype: Dash
+ """
+
+ dash_app = dash.Dash(
+ server=server,
+ routes_pathname_prefix=C.COVERAGE_ROUTES_PATHNAME_PREFIX,
+ external_stylesheets=C.EXTERNAL_STYLESHEETS,
+ title=C.COVERAGE_TITLE
+ )
+
+ layout = Layout(
+ app=dash_app,
+ data_coverage=data_coverage,
+ html_layout_file=C.HTML_LAYOUT_FILE,
+ tooltip_file=C.TOOLTIP_FILE
+ )
+ dash_app.index_string = layout.html_layout
+ dash_app.layout = layout.add_content()
+
+ return dash_app.server
diff --git a/csit.infra.dash/app/cdash/coverage/layout.py b/csit.infra.dash/app/cdash/coverage/layout.py
new file mode 100644
index 0000000000..b8fa0236a5
--- /dev/null
+++ b/csit.infra.dash/app/cdash/coverage/layout.py
@@ -0,0 +1,753 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Plotly Dash HTML layout override.
+"""
+
+
+import logging
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from flask import Flask
+from dash import dcc
+from dash import html
+from dash import callback_context, no_update, ALL
+from dash import Input, Output, State
+from dash.exceptions import PreventUpdate
+from ast import literal_eval
+from yaml import load, FullLoader, YAMLError
+
+from ..utils.constants import Constants as C
+from ..utils.control_panel import ControlPanel
+from ..utils.trigger import Trigger
+from ..utils.utils import label, gen_new_url, generate_options, navbar_report, \
+ show_tooltip
+from ..utils.url_processing import url_decode
+from .tables import coverage_tables, select_coverage_data
+
+
+# Control panel partameters and their default values.
+CP_PARAMS = {
+ "rls-val": str(),
+ "dut-opt": list(),
+ "dut-dis": True,
+ "dut-val": str(),
+ "dutver-opt": list(),
+ "dutver-dis": True,
+ "dutver-val": str(),
+ "phy-opt": list(),
+ "phy-dis": True,
+ "phy-val": str(),
+ "area-opt": list(),
+ "area-dis": True,
+ "area-val": str(),
+ "show-latency": ["show_latency", ]
+}
+
+
+class Layout:
+ """The layout of the dash app and the callbacks.
+ """
+
+ def __init__(
+ self,
+ app: Flask,
+ data_coverage: pd.DataFrame,
+ html_layout_file: str,
+ tooltip_file: str
+ ) -> None:
+ """Initialization:
+ - save the input parameters,
+ - prepare data for the control panel,
+ - read HTML layout file,
+
+ :param app: Flask application running the dash application.
+ :param html_layout_file: Path and name of the file specifying the HTML
+ layout of the dash application.
+ :param tooltip_file: Path and name of the yaml file specifying the
+ tooltips.
+ :type app: Flask
+ :type html_layout_file: str
+ :type tooltip_file: str
+ """
+
+ # Inputs
+ self._app = app
+ self._data = data_coverage
+ self._html_layout_file = html_layout_file
+ self._tooltip_file = tooltip_file
+
+ # Get structure of tests:
+ tbs = dict()
+ cols = ["job", "test_id", "dut_version", "release", ]
+ for _, row in self._data[cols].drop_duplicates().iterrows():
+ rls = row["release"]
+ lst_job = row["job"].split("-")
+ dut = lst_job[1]
+ d_ver = row["dut_version"]
+ tbed = "-".join(lst_job[-2:])
+ lst_test_id = row["test_id"].split(".")
+ if dut == "dpdk":
+ area = "dpdk"
+ else:
+ area = ".".join(lst_test_id[3:-2])
+ suite = lst_test_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
+ replace("2n-", "")
+ test = lst_test_id[-1]
+ nic = suite.split("-")[0]
+ for drv in C.DRIVERS:
+ if drv in test:
+ driver = drv.replace("-", "_")
+ test = test.replace(f"{drv}-", "")
+ break
+ else:
+ driver = "dpdk"
+ infra = "-".join((tbed, nic, driver))
+
+ if tbs.get(rls, None) is None:
+ tbs[rls] = dict()
+ if tbs[rls].get(dut, None) is None:
+ tbs[rls][dut] = dict()
+ if tbs[rls][dut].get(d_ver, None) is None:
+ tbs[rls][dut][d_ver] = dict()
+ if tbs[rls][dut][d_ver].get(area, None) is None:
+ tbs[rls][dut][d_ver][area] = list()
+ if infra not in tbs[rls][dut][d_ver][area]:
+ tbs[rls][dut][d_ver][area].append(infra)
+
+ self._spec_tbs = tbs
+
+ # Read from files:
+ self._html_layout = str()
+
+ try:
+ with open(self._html_layout_file, "r") as file_read:
+ self._html_layout = file_read.read()
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._html_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._tooltip_file, "r") as file_read:
+ self._tooltips = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ logging.warning(
+ f"Not possible to open the file {self._tooltip_file}\n{err}"
+ )
+ except YAMLError as err:
+ logging.warning(
+ f"An error occurred while parsing the specification file "
+ f"{self._tooltip_file}\n{err}"
+ )
+
+ # Callbacks:
+ if self._app is not None and hasattr(self, "callbacks"):
+ self.callbacks(self._app)
+
+ @property
+ def html_layout(self):
+ return self._html_layout
+
+ def add_content(self):
+ """Top level method which generated the web page.
+
+ It generates:
+ - Store for user input data,
+ - Navigation bar,
+ - Main area with control panel and ploting area.
+
+ If no HTML layout is provided, an error message is displayed instead.
+
+ :returns: The HTML div with the whole page.
+ :rtype: html.Div
+ """
+
+ if self.html_layout and self._spec_tbs:
+ return html.Div(
+ id="div-main",
+ className="small",
+ children=[
+ dbc.Row(
+ id="row-navbar",
+ class_name="g-0",
+ children=[navbar_report((False, False, True, False)), ]
+ ),
+ dbc.Row(
+ id="row-main",
+ class_name="g-0",
+ children=[
+ dcc.Store(id="store-selected-tests"),
+ dcc.Store(id="store-control-panel"),
+ dcc.Location(id="url", refresh=False),
+ self._add_ctrl_col(),
+ self._add_plotting_col()
+ ]
+ ),
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-documentation",
+ title="Documentation",
+ placement="end",
+ is_open=False,
+ children=html.Iframe(
+ src=C.URL_DOC_REL_NOTES,
+ width="100%",
+ height="100%"
+ )
+ )
+ ]
+ )
+ else:
+ return html.Div(
+ id="div-main-error",
+ children=[
+ dbc.Alert(
+ [
+ "An Error Occured"
+ ],
+ color="danger"
+ )
+ ]
+ )
+
+ def _add_ctrl_col(self) -> dbc.Col:
+ """Add column with controls. It is placed on the left side.
+
+ :returns: Column with the control panel.
+ :rtype: dbc.Col
+ """
+ return dbc.Col([
+ html.Div(
+ children=self._add_ctrl_panel(),
+ className="sticky-top"
+ )
+ ])
+
+ def _add_plotting_col(self) -> dbc.Col:
+ """Add column with plots. It is placed on the right side.
+
+ :returns: Column with plots.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(
+ id="col-plotting-area",
+ children=[
+ dbc.Spinner(
+ children=[
+ dbc.Row(
+ id="plotting-area",
+ class_name="g-0 p-0",
+ children=[
+ C.PLACEHOLDER
+ ]
+ )
+ ]
+ )
+ ],
+ width=9
+ )
+
+ def _add_ctrl_panel(self) -> list:
+ """Add control panel.
+
+ :returns: Control panel.
+ :rtype: list
+ """
+ return [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-release",
+ "CSIT Release"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "rls"},
+ placeholder="Select a Release...",
+ options=sorted(
+ [
+ {"label": k, "value": k} \
+ for k in self._spec_tbs.keys()
+ ],
+ key=lambda d: d["label"]
+ )
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-dut",
+ "DUT"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dut"},
+ placeholder="Select a Device under Test..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-dut-ver",
+ "DUT Version"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dutver"},
+ placeholder=\
+ "Select a Version of Device under Test..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-area",
+ "Area"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "area"},
+ placeholder="Select an Area..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-infra",
+ "Infra"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "phy"},
+ placeholder=\
+ "Select a Physical Test Bed Topology..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-show-latency",
+ "Latency"
+ )),
+ dbc.Checklist(
+ id="show-latency",
+ options=[{
+ "value": "show_latency",
+ "label": "Show Latency"
+ }],
+ value=["show_latency"],
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ )
+ ]
+
+ def _get_plotting_area(
+ self,
+ selected: dict,
+ url: str,
+ show_latency: bool
+ ) -> list:
+ """Generate the plotting area with all its content.
+
+ :param selected: Selected parameters of tests.
+ :param url: URL to be displayed in the modal window.
+ :param show_latency: If True, latency is displayed in the tables.
+ :type selected: dict
+ :type url: str
+ :type show_latency: bool
+ :returns: List of rows with elements to be displayed in the plotting
+ area.
+ :rtype: list
+ """
+ if not selected:
+ return C.PLACEHOLDER
+
+ return [
+ dbc.Row(
+ children=coverage_tables(self._data, selected, show_latency),
+ class_name="g-0 p-0",
+ ),
+ dbc.Row(
+ children=C.PLACEHOLDER,
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ [
+ dbc.Col([html.Div(
+ [
+ dbc.Button(
+ id="plot-btn-url",
+ children="Show URL",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(dbc.ModalTitle("URL")),
+ dbc.ModalBody(url)
+ ],
+ id="plot-mod-url",
+ size="xl",
+ is_open=False,
+ scrollable=True
+ ),
+ dbc.Button(
+ id="plot-btn-download",
+ children="Download Data",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dcc.Download(id="download-iterative-data")
+ ],
+ className=\
+ "d-grid gap-0 d-md-flex justify-content-md-end"
+ )])
+ ],
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ children=C.PLACEHOLDER,
+ class_name="g-0 p-1"
+ )
+ ]
+
+ def callbacks(self, app):
+ """Callbacks for the whole application.
+
+ :param app: The application.
+ :type app: Flask
+ """
+
+ @app.callback(
+ [
+ Output("store-control-panel", "data"),
+ Output("store-selected-tests", "data"),
+ Output("plotting-area", "children"),
+ Output({"type": "ctrl-dd", "index": "rls"}, "value"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "options"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "value"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "options"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "value"),
+ Output({"type": "ctrl-dd", "index": "area"}, "options"),
+ Output({"type": "ctrl-dd", "index": "area"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "area"}, "value"),
+ Output("show-latency", "value"),
+ ],
+ [
+ State("store-control-panel", "data"),
+ State("store-selected-tests", "data")
+ ],
+ [
+ Input("url", "href"),
+ Input("show-latency", "value"),
+ Input({"type": "ctrl-dd", "index": ALL}, "value")
+ ]
+ )
+ def _update_application(
+ control_panel: dict,
+ selected: dict,
+ href: str,
+ show_latency: list,
+ *_
+ ) -> tuple:
+ """Update the application when the event is detected.
+ """
+
+ ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
+ plotting_area = no_update
+ on_draw = False
+ if selected is None:
+ selected = dict()
+
+ # Parse the url:
+ parsed_url = url_decode(href)
+ if parsed_url:
+ url_params = parsed_url["params"]
+ else:
+ url_params = None
+
+ trigger = Trigger(callback_context.triggered)
+
+ if trigger.type == "url" and url_params:
+ try:
+ show_latency = literal_eval(url_params["show_latency"][0])
+ selected = literal_eval(url_params["selection"][0])
+ except (KeyError, IndexError, AttributeError):
+ pass
+ if selected:
+ ctrl_panel.set({
+ "rls-val": selected["rls"],
+ "dut-val": selected["dut"],
+ "dut-opt": generate_options(
+ self._spec_tbs[selected["rls"]].keys()
+ ),
+ "dut-dis": False,
+ "dutver-val": selected["dutver"],
+ "dutver-opt": generate_options(
+ self._spec_tbs[selected["rls"]]\
+ [selected["dut"]].keys()
+ ),
+ "dutver-dis": False,
+ "area-val": selected["area"],
+ "area-opt": [
+ {"label": label(v), "value": v} \
+ for v in sorted(self._spec_tbs[selected["rls"]]\
+ [selected["dut"]]\
+ [selected["dutver"]].keys())
+ ],
+ "area-dis": False,
+ "phy-val": selected["phy"],
+ "phy-opt": generate_options(
+ self._spec_tbs[selected["rls"]][selected["dut"]]\
+ [selected["dutver"]][selected["area"]]
+ ),
+ "phy-dis": False,
+ "show-latency": show_latency
+ })
+ on_draw = True
+ elif trigger.type == "show-latency":
+ ctrl_panel.set({"show-latency": show_latency})
+ on_draw = True
+ elif trigger.type == "ctrl-dd":
+ if trigger.idx == "rls":
+ try:
+ options = generate_options(
+ self._spec_tbs[trigger.value].keys()
+ )
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "rls-val": trigger.value,
+ "dut-val": str(),
+ "dut-opt": options,
+ "dut-dis": disabled,
+ "dutver-val": str(),
+ "dutver-opt": list(),
+ "dutver-dis": True,
+ "phy-val": str(),
+ "phy-opt": list(),
+ "phy-dis": True,
+ "area-val": str(),
+ "area-opt": list(),
+ "area-dis": True
+ })
+ elif trigger.idx == "dut":
+ try:
+ rls = ctrl_panel.get("rls-val")
+ dut = self._spec_tbs[rls][trigger.value]
+ options = generate_options(dut.keys())
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dut-val": trigger.value,
+ "dutver-val": str(),
+ "dutver-opt": options,
+ "dutver-dis": disabled,
+ "phy-val": str(),
+ "phy-opt": list(),
+ "phy-dis": True,
+ "area-val": str(),
+ "area-opt": list(),
+ "area-dis": True
+ })
+ elif trigger.idx == "dutver":
+ try:
+ rls = ctrl_panel.get("rls-val")
+ dut = ctrl_panel.get("dut-val")
+ ver = self._spec_tbs[rls][dut][trigger.value]
+ options = [
+ {"label": label(v), "value": v} for v in sorted(ver)
+ ]
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dutver-val": trigger.value,
+ "area-val": str(),
+ "area-opt": options,
+ "area-dis": disabled,
+ "phy-val": str(),
+ "phy-opt": list(),
+ "phy-dis": True
+ })
+ elif trigger.idx == "area":
+ try:
+ rls = ctrl_panel.get("rls-val")
+ dut = ctrl_panel.get("dut-val")
+ ver = ctrl_panel.get("dutver-val")
+ options = generate_options(
+ self._spec_tbs[rls][dut][ver][trigger.value])
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "area-val": trigger.value,
+ "phy-val": str(),
+ "phy-opt": options,
+ "phy-dis": disabled
+ })
+ elif trigger.idx == "phy":
+ ctrl_panel.set({"phy-val": trigger.value})
+ selected = {
+ "rls": ctrl_panel.get("rls-val"),
+ "dut": ctrl_panel.get("dut-val"),
+ "dutver": ctrl_panel.get("dutver-val"),
+ "phy": ctrl_panel.get("phy-val"),
+ "area": ctrl_panel.get("area-val"),
+ }
+ on_draw = True
+
+ if on_draw:
+ if selected:
+ plotting_area = self._get_plotting_area(
+ selected,
+ gen_new_url(
+ parsed_url,
+ {
+ "selection": selected,
+ "show_latency": show_latency
+ }
+ ),
+ show_latency=bool(show_latency)
+ )
+ else:
+ plotting_area = C.PLACEHOLDER
+ selected = dict()
+
+ ret_val = [
+ ctrl_panel.panel,
+ selected,
+ plotting_area,
+ ]
+ ret_val.extend(ctrl_panel.values)
+ return ret_val
+
+ @app.callback(
+ Output("plot-mod-url", "is_open"),
+ [Input("plot-btn-url", "n_clicks")],
+ [State("plot-mod-url", "is_open")],
+ )
+ def toggle_plot_mod_url(n, is_open):
+ """Toggle the modal window with url.
+ """
+ if n:
+ return not is_open
+ return is_open
+
+ @app.callback(
+ Output("download-iterative-data", "data"),
+ State("store-selected-tests", "data"),
+ State("show-latency", "value"),
+ Input("plot-btn-download", "n_clicks"),
+ prevent_initial_call=True
+ )
+ def _download_coverage_data(selection, show_latency, _):
+ """Download the data
+
+ :param selection: List of tests selected by user stored in the
+ browser.
+ :param show_latency: If True, latency is displayed in the tables.
+ :type selection: dict
+ :type show_latency: bool
+ :returns: dict of data frame content (base64 encoded) and meta data
+ used by the Download component.
+ :rtype: dict
+ """
+
+ if not selection:
+ raise PreventUpdate
+
+ df = select_coverage_data(
+ self._data,
+ selection,
+ csv=True,
+ show_latency=bool(show_latency)
+ )
+
+ return dcc.send_data_frame(df.to_csv, C.COVERAGE_DOWNLOAD_FILE_NAME)
+
+ @app.callback(
+ Output("offcanvas-documentation", "is_open"),
+ Input("btn-documentation", "n_clicks"),
+ State("offcanvas-documentation", "is_open")
+ )
+ def toggle_offcanvas_documentation(n_clicks, is_open):
+ if n_clicks:
+ return not is_open
+ return is_open
diff --git a/csit.infra.dash/app/cdash/coverage/tables.py b/csit.infra.dash/app/cdash/coverage/tables.py
new file mode 100644
index 0000000000..84adb091a1
--- /dev/null
+++ b/csit.infra.dash/app/cdash/coverage/tables.py
@@ -0,0 +1,315 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""The coverage data tables.
+"""
+
+
+import hdrh.histogram
+import hdrh.codec
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from dash import dash_table
+from dash.dash_table.Format import Format, Scheme
+
+from ..utils.constants import Constants as C
+
+
+def select_coverage_data(
+ data: pd.DataFrame,
+ selected: dict,
+ csv: bool=False,
+ show_latency: bool=True
+ ) -> list:
+ """Select coverage data for the tables and generate tables as pandas data
+ frames.
+
+ :param data: Coverage data.
+ :param selected: Dictionary with user selection.
+ :param csv: If True, pandas data frame with selected coverage data is
+ returned for "Download Data" feature.
+ :param show_latency: If True, latency is displayed in the tables.
+ :type data: pandas.DataFrame
+ :type selected: dict
+ :type csv: bool
+ :type show_latency: bool
+ :returns: List of tuples with suite name (str) and data (pandas dataframe)
+ or pandas dataframe if csv is True.
+ :rtype: list[tuple[str, pandas.DataFrame], ] or pandas.DataFrame
+ """
+
+ l_data = list()
+
+ # Filter data selected by the user.
+ phy = selected["phy"].split("-")
+ if len(phy) == 4:
+ topo, arch, nic, drv = phy
+ drv_str = "" if drv == "dpdk" else drv.replace("_", "-")
+ else:
+ return l_data, None
+
+ df = pd.DataFrame(data.loc[(
+ (data["passed"] == True) &
+ (data["dut_type"] == selected["dut"]) &
+ (data["dut_version"] == selected["dutver"]) &
+ (data["release"] == selected["rls"])
+ )])
+ df = df[
+ (df.job.str.endswith(f"{topo}-{arch}")) &
+ (df.test_id.str.contains(
+ f"^.*\.{selected['area']}\..*{nic}.*{drv_str}.*$",
+ regex=True
+ ))
+ ]
+ if drv == "dpdk":
+ for driver in C.DRIVERS:
+ df.drop(
+ df[df.test_id.str.contains(f"-{driver}-")].index,
+ inplace=True
+ )
+ try:
+ ttype = df["test_type"].to_list()[0]
+ except IndexError:
+ return l_data, None
+
+ # Prepare the coverage data
+ def _latency(hdrh_string: str, percentile: float) -> int:
+ """Get latency from HDRH string for given percentile.
+
+ :param hdrh_string: Encoded HDRH string.
+ :param percentile: Given percentile.
+ :type hdrh_string: str
+ :type percentile: float
+ :returns: The latency value for the given percentile from the encoded
+ HDRH string.
+ :rtype: int
+ """
+ try:
+ hdr_lat = hdrh.histogram.HdrHistogram.decode(hdrh_string)
+ return hdr_lat.get_value_at_percentile(percentile)
+ except (hdrh.codec.HdrLengthException, TypeError):
+ return None
+
+ def _get_suite(test_id: str) -> str:
+ """Get the suite name from the test ID.
+ """
+ return test_id.split(".")[-2].replace("2n1l-", "").\
+ replace("1n1l-", "").replace("2n-", "").replace("-ndrpdr", "")
+
+ def _get_test(test_id: str) -> str:
+ """Get the test name from the test ID.
+ """
+ return test_id.split(".")[-1].replace("-ndrpdr", "")
+
+ cov = pd.DataFrame()
+ cov["Suite"] = df.apply(lambda row: _get_suite(row["test_id"]), axis=1)
+ cov["Test Name"] = df.apply(lambda row: _get_test(row["test_id"]), axis=1)
+
+ if ttype == "device":
+ cov = cov.assign(Result="PASS")
+ elif ttype == "mrr":
+ cov["Throughput_Unit"] = df["result_receive_rate_rate_unit"]
+ cov["Throughput_AVG"] = df.apply(
+ lambda row: row["result_receive_rate_rate_avg"] / 1e9, axis=1
+ )
+ cov["Throughput_STDEV"] = df.apply(
+ lambda row: row["result_receive_rate_rate_stdev"] / 1e9, axis=1
+ )
+ else: # NDRPDR
+ cov["Throughput_Unit"] = df["result_pdr_lower_rate_unit"]
+ cov["Throughput_NDR"] = df.apply(
+ lambda row: row["result_ndr_lower_rate_value"] / 1e6, axis=1
+ )
+ cov["Throughput_NDR_Gbps"] = df.apply(
+ lambda row: row["result_ndr_lower_bandwidth_value"] / 1e9, axis=1
+ )
+ cov["Throughput_PDR"] = df.apply(
+ lambda row: row["result_pdr_lower_rate_value"] / 1e6, axis=1
+ )
+ cov["Throughput_PDR_Gbps"] = df.apply(
+ lambda row: row["result_pdr_lower_bandwidth_value"] / 1e9, axis=1
+ )
+ if show_latency:
+ for way in ("Forward", "Reverse"):
+ for pdr in (10, 50, 90):
+ for perc in (50, 90, 99):
+ latency = f"result_latency_{way.lower()}_pdr_{pdr}_hdrh"
+ cov[f"Latency {way} [us]_{pdr}% PDR_P{perc}"] = \
+ df.apply(
+ lambda row: _latency(row[latency], perc),
+ axis=1
+ )
+
+ if csv:
+ return cov
+
+ # Split data into tables depending on the test suite.
+ for suite in cov["Suite"].unique().tolist():
+ df_suite = pd.DataFrame(cov.loc[(cov["Suite"] == suite)])
+
+ if ttype !="device":
+ unit = df_suite["Throughput_Unit"].tolist()[0]
+ df_suite.rename(
+ columns={
+ "Throughput_NDR": f"Throughput_NDR_M{unit}",
+ "Throughput_PDR": f"Throughput_PDR_M{unit}",
+ "Throughput_AVG": f"Throughput_G{unit}_AVG",
+ "Throughput_STDEV": f"Throughput_G{unit}_STDEV"
+ },
+ inplace=True
+ )
+ df_suite.drop(["Suite", "Throughput_Unit"], axis=1, inplace=True)
+
+ l_data.append((suite, df_suite, ))
+
+ return l_data, ttype
+
+
+def coverage_tables(
+ data: pd.DataFrame,
+ selected: dict,
+ show_latency: bool=True,
+ start_collapsed: bool=True
+ ) -> dbc.Accordion:
+ """Generate an accordion with coverage tables.
+
+ :param data: Coverage data.
+ :param selected: Dictionary with user selection.
+ :param show_latency: If True, latency is displayed in the tables.
+ :param start_collapsed: If True, the accordion with tables is collapsed when
+ displayed.
+ :type data: pandas.DataFrame
+ :type selected: dict
+ :type show_latency: bool
+ :type start_collapsed: bool
+ :returns: Accordion with suite names (titles) and tables.
+ :rtype: dash_bootstrap_components.Accordion
+ """
+
+ accordion_items = list()
+ sel_data, ttype = \
+ select_coverage_data(data, selected, show_latency=show_latency)
+ for suite, cov_data in sel_data:
+ if ttype == "device": # VPP Device
+ cols = [
+ {
+ "name": col,
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "text"
+ } for col in cov_data.columns
+ ]
+ style_cell={"textAlign": "left"}
+ style_cell_conditional=[
+ {
+ "if": {"column_id": "Result"},
+ "textAlign": "right"
+ }
+ ]
+ elif ttype == "mrr": # MRR
+ cols = list()
+ for idx, col in enumerate(cov_data.columns):
+ if idx == 0:
+ cols.append({
+ "name": ["", "", col],
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "text"
+ })
+ else:
+ cols.append({
+ "name": col.split("_"),
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "numeric",
+ "format": Format(precision=2, scheme=Scheme.fixed)
+ })
+ style_cell={"textAlign": "right"}
+ style_cell_conditional=[
+ {
+ "if": {"column_id": "Test Name"},
+ "textAlign": "left"
+ }
+ ]
+ else: # Performance NDRPDR
+ cols = list()
+ for idx, col in enumerate(cov_data.columns):
+ if idx == 0:
+ cols.append({
+ "name": ["", "", col],
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "text"
+ })
+ elif idx < 5:
+ cols.append({
+ "name": col.split("_"),
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "numeric",
+ "format": Format(precision=2, scheme=Scheme.fixed)
+ })
+ else:
+ cols.append({
+ "name": col.split("_"),
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "numeric",
+ "format": Format(precision=0, scheme=Scheme.fixed)
+ })
+ style_cell={"textAlign": "right"}
+ style_cell_conditional=[
+ {
+ "if": {"column_id": "Test Name"},
+ "textAlign": "left"
+ }
+ ]
+
+ accordion_items.append(
+ dbc.AccordionItem(
+ title=suite,
+ children=dash_table.DataTable(
+ columns=cols,
+ data=cov_data.to_dict("records"),
+ merge_duplicate_headers=True,
+ editable=False,
+ filter_action="none",
+ sort_action="native",
+ sort_mode="multi",
+ selected_columns=[],
+ selected_rows=[],
+ page_action="none",
+ style_cell=style_cell,
+ style_cell_conditional=style_cell_conditional
+ )
+ )
+ )
+ if not accordion_items:
+ accordion_items.append(dbc.AccordionItem(
+ title="No data.",
+ children="No data."
+ ))
+ start_collapsed = True
+ return dbc.Accordion(
+ children=accordion_items,
+ class_name="gy-1 p-0",
+ start_collapsed=start_collapsed,
+ always_open=True
+ )
diff --git a/csit.infra.dash/app/cdash/data/__init__.py b/csit.infra.dash/app/cdash/data/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2306_device b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2306_device
new file mode 100644
index 0000000000..f619ce8a8e
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2306_device
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2306_ndrpdr b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2306_ndrpdr
new file mode 100644
index 0000000000..06bc618bea
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2306_ndrpdr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2310_device b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2310_device
new file mode 100644
index 0000000000..f619ce8a8e
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2310_device
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2310_ndrpdr b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2310_ndrpdr
new file mode 100644
index 0000000000..06bc618bea
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2310_ndrpdr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2402_device b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2402_device
new file mode 100644
index 0000000000..f619ce8a8e
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2402_device
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2402_ndrpdr b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2402_ndrpdr
new file mode 100644
index 0000000000..06bc618bea
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/coverage_rls2402_ndrpdr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_hoststack b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_hoststack
new file mode 100644
index 0000000000..993d16c18c
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_hoststack
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_mrr b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_mrr
new file mode 100644
index 0000000000..96832850b1
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_mrr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_ndrpdr b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_ndrpdr
new file mode 100644
index 0000000000..2291bb8349
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2306_ndrpdr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_hoststack b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_hoststack
new file mode 100644
index 0000000000..993d16c18c
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_hoststack
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_mrr b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_mrr
new file mode 100644
index 0000000000..96832850b1
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_mrr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_ndrpdr b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_ndrpdr
new file mode 100644
index 0000000000..e76e6ab8e5
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2310_ndrpdr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_hoststack b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_hoststack
new file mode 100644
index 0000000000..1e9c708253
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_hoststack
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_mrr b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_mrr
new file mode 100644
index 0000000000..416679acdb
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_mrr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_ndrpdr b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_ndrpdr
new file mode 100644
index 0000000000..e76e6ab8e5
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/iterative_rls2402_ndrpdr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/statistics b/csit.infra.dash/app/cdash/data/_metadata/statistics
new file mode 100644
index 0000000000..208e119735
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/statistics
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/trending_hoststack b/csit.infra.dash/app/cdash/data/_metadata/trending_hoststack
new file mode 100644
index 0000000000..f6ab72be9a
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/trending_hoststack
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/trending_mrr b/csit.infra.dash/app/cdash/data/_metadata/trending_mrr
new file mode 100644
index 0000000000..9d4e126e59
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/trending_mrr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/trending_ndrpdr b/csit.infra.dash/app/cdash/data/_metadata/trending_ndrpdr
new file mode 100644
index 0000000000..3f8b85c66e
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/trending_ndrpdr
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/_metadata/trending_soak b/csit.infra.dash/app/cdash/data/_metadata/trending_soak
new file mode 100644
index 0000000000..4502ca4f59
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/_metadata/trending_soak
Binary files differ
diff --git a/csit.infra.dash/app/cdash/data/data.py b/csit.infra.dash/app/cdash/data/data.py
new file mode 100644
index 0000000000..2c49992bf8
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/data.py
@@ -0,0 +1,421 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Prepare data for Plotly Dash applications.
+"""
+
+import logging
+import resource
+import awswrangler as wr
+import pandas as pd
+import pyarrow as pa
+
+from yaml import load, FullLoader, YAMLError
+from datetime import datetime, timedelta
+from time import time
+from pytz import UTC
+from awswrangler.exceptions import EmptyDataFrame, NoFilesFound
+from pyarrow.lib import ArrowInvalid, ArrowNotImplementedError
+
+from ..utils.constants import Constants as C
+
+
+# If True, pyarrow.Schema is generated. See also condition in the method
+# _write_parquet_schema.
+# To generate schema, select only one data set in data.yaml file.
+GENERATE_SCHEMA = False
+
+
+class Data:
+ """Gets the data from parquets and stores it for further use by dash
+ applications.
+ """
+
+ def __init__(self, data_spec_file: str) -> None:
+ """Initialize the Data object.
+
+ :param data_spec_file: Path to file specifying the data to be read from
+ parquets.
+ :type data_spec_file: str
+ :raises RuntimeError: if it is not possible to open data_spec_file or it
+ is not a valid yaml file.
+ """
+
+ # Inputs:
+ self._data_spec_file = data_spec_file
+
+ # Specification of data to be read from parquets:
+ self._data_spec = list()
+
+ # Data frame to keep the data:
+ self._data = {
+ "statistics": pd.DataFrame(),
+ "trending": pd.DataFrame(),
+ "iterative": pd.DataFrame(),
+ "coverage": pd.DataFrame()
+ }
+
+ # Read from files:
+ try:
+ with open(self._data_spec_file, "r") as file_read:
+ self._data_spec = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._data_spec_file,}\n{err}"
+ )
+ except YAMLError as err:
+ raise RuntimeError(
+ f"An error occurred while parsing the specification file "
+ f"{self._data_spec_file,}\n"
+ f"{err}"
+ )
+
+ @property
+ def data(self):
+ return self._data
+
+ @staticmethod
+ def _get_list_of_files(
+ path,
+ last_modified_begin=None,
+ last_modified_end=None,
+ days=None
+ ) -> list:
+ """Get list of interested files stored in S3 compatible storage and
+ returns it.
+
+ :param path: S3 prefix (accepts Unix shell-style wildcards)
+ (e.g. s3://bucket/prefix) or list of S3 objects paths
+ (e.g. [s3://bucket/key0, s3://bucket/key1]).
+ :param last_modified_begin: Filter the s3 files by the Last modified
+ date of the object. The filter is applied only after list all s3
+ files.
+ :param last_modified_end: Filter the s3 files by the Last modified date
+ of the object. The filter is applied only after list all s3 files.
+ :param days: Number of days to filter.
+ :type path: Union[str, List[str]]
+ :type last_modified_begin: datetime, optional
+ :type last_modified_end: datetime, optional
+ :type days: integer, optional
+ :returns: List of file names.
+ :rtype: list
+ """
+ file_list = list()
+ if days:
+ last_modified_begin = datetime.now(tz=UTC) - timedelta(days=days)
+ try:
+ file_list = wr.s3.list_objects(
+ path=path,
+ suffix="parquet",
+ last_modified_begin=last_modified_begin,
+ last_modified_end=last_modified_end
+ )
+ logging.debug("\n".join(file_list))
+ except NoFilesFound as err:
+ logging.error(f"No parquets found.\n{err}")
+ except EmptyDataFrame as err:
+ logging.error(f"No data.\n{err}")
+
+ return file_list
+
+ def _validate_columns(self, data_type: str) -> str:
+ """Check if all columns are present in the dataframe.
+
+ :param data_type: The data type defined in data.yaml
+ :type data_type: str
+ :returns: Error message if validation fails, otherwise empty string.
+ :rtype: str
+ """
+ defined_columns = set()
+ for data_set in self._data_spec:
+ if data_set.get("data_type", str()) == data_type:
+ defined_columns.update(data_set.get("columns", set()))
+
+ if not defined_columns:
+ return "No columns defined in the data set(s)."
+
+ if self.data[data_type].empty:
+ return "No data."
+
+ ret_msg = str()
+ for col in defined_columns:
+ if col not in self.data[data_type].columns:
+ if not ret_msg:
+ ret_msg = "Missing columns: "
+ else:
+ ret_msg += ", "
+ ret_msg += f"{col}"
+ return ret_msg
+
+ @staticmethod
+ def _write_parquet_schema(
+ path,
+ partition_filter=None,
+ columns=None,
+ validate_schema=False,
+ last_modified_begin=None,
+ last_modified_end=None,
+ days=None
+ ) -> None:
+ """Auxiliary function to write parquet schemas. Use it instead of
+ "_create_dataframe_from_parquet" in "read_all_data".
+
+ :param path: S3 prefix (accepts Unix shell-style wildcards)
+ (e.g. s3://bucket/prefix) or list of S3 objects paths
+ (e.g. [s3://bucket/key0, s3://bucket/key1]).
+ :param partition_filter: Callback Function filters to apply on PARTITION
+ columns (PUSH-DOWN filter). This function MUST receive a single
+ argument (Dict[str, str]) where keys are partitions names and values
+ are partitions values. Partitions values will be always strings
+ extracted from S3. This function MUST return a bool, True to read
+ the partition or False to ignore it. Ignored if dataset=False.
+ :param columns: Names of columns to read from the file(s).
+ :param validate_schema: Check that individual file schemas are all the
+ same / compatible. Schemas within a folder prefix should all be the
+ same. Disable if you have schemas that are different and want to
+ disable this check.
+ :param last_modified_begin: Filter the s3 files by the Last modified
+ date of the object. The filter is applied only after list all s3
+ files.
+ :param last_modified_end: Filter the s3 files by the Last modified date
+ of the object. The filter is applied only after list all s3 files.
+ :param days: Number of days to filter.
+ :type path: Union[str, List[str]]
+ :type partition_filter: Callable[[Dict[str, str]], bool], optional
+ :type columns: List[str], optional
+ :type validate_schema: bool, optional
+ :type last_modified_begin: datetime, optional
+ :type last_modified_end: datetime, optional
+ :type days: integer, optional
+ """
+ if days:
+ last_modified_begin = datetime.now(tz=UTC) - timedelta(days=days)
+
+ df = wr.s3.read_parquet(
+ path=path,
+ path_suffix="parquet",
+ ignore_empty=True,
+ validate_schema=validate_schema,
+ use_threads=True,
+ dataset=True,
+ columns=columns,
+ partition_filter=partition_filter,
+ last_modified_begin=last_modified_begin,
+ last_modified_end=last_modified_end,
+ chunked=1
+ )
+
+ for itm in df:
+ try:
+ # Specify the condition or remove it:
+ if all((
+ pd.api.types.is_string_dtype(itm["column_name"]),
+ pd.api.types.is_string_dtype(itm["telemetry"][0])
+ )):
+ schema = pa.Schema.from_pandas(itm)
+ pa.parquet.write_metadata(
+ schema, f"{C.PATH_TO_SCHEMAS}_tmp_schema"
+ )
+ logging.info(schema.to_string(
+ truncate_metadata=False,
+ show_field_metadata=True,
+ show_schema_metadata=True
+ ))
+ break
+ except KeyError:
+ pass
+
+ @staticmethod
+ def _create_dataframe_from_parquet(
+ path,
+ partition_filter=None,
+ columns=None,
+ validate_schema=False,
+ last_modified_begin=None,
+ last_modified_end=None,
+ days=None,
+ schema=None
+ ) -> pd.DataFrame:
+ """Read parquet stored in S3 compatible storage and returns Pandas
+ Dataframe.
+
+ :param path: S3 prefix (accepts Unix shell-style wildcards)
+ (e.g. s3://bucket/prefix) or list of S3 objects paths
+ (e.g. [s3://bucket/key0, s3://bucket/key1]).
+ :param partition_filter: Callback Function filters to apply on PARTITION
+ columns (PUSH-DOWN filter). This function MUST receive a single
+ argument (Dict[str, str]) where keys are partitions names and values
+ are partitions values. Partitions values will be always strings
+ extracted from S3. This function MUST return a bool, True to read
+ the partition or False to ignore it. Ignored if dataset=False.
+ :param columns: Names of columns to read from the file(s).
+ :param validate_schema: Check that individual file schemas are all the
+ same / compatible. Schemas within a folder prefix should all be the
+ same. Disable if you have schemas that are different and want to
+ disable this check.
+ :param last_modified_begin: Filter the s3 files by the Last modified
+ date of the object. The filter is applied only after list all s3
+ files.
+ :param last_modified_end: Filter the s3 files by the Last modified date
+ of the object. The filter is applied only after list all s3 files.
+ :param days: Number of days to filter.
+ :param schema: Path to schema to use when reading data from the parquet.
+ :type path: Union[str, List[str]]
+ :type partition_filter: Callable[[Dict[str, str]], bool], optional
+ :type columns: List[str], optional
+ :type validate_schema: bool, optional
+ :type last_modified_begin: datetime, optional
+ :type last_modified_end: datetime, optional
+ :type days: integer, optional
+ :type schema: string
+ :returns: Pandas DataFrame or None if DataFrame cannot be fetched.
+ :rtype: DataFrame
+ """
+ df = pd.DataFrame()
+ start = time()
+ if days:
+ last_modified_begin = datetime.now(tz=UTC) - timedelta(days=days)
+ try:
+ df = wr.s3.read_parquet(
+ path=path,
+ path_suffix="parquet",
+ ignore_empty=True,
+ schema=schema,
+ validate_schema=validate_schema,
+ use_threads=True,
+ dataset=True,
+ columns=columns,
+ partition_filter=partition_filter,
+ last_modified_begin=last_modified_begin,
+ last_modified_end=last_modified_end,
+ dtype_backend="pyarrow"
+ )
+
+ df.info(verbose=True, memory_usage="deep")
+ logging.debug(
+ f"\nCreation of dataframe {path} took: {time() - start}\n"
+ )
+ except (ArrowInvalid, ArrowNotImplementedError) as err:
+ logging.error(f"Reading of data from parquets FAILED.\n{repr(err)}")
+ except NoFilesFound as err:
+ logging.error(
+ f"Reading of data from parquets FAILED.\n"
+ f"No parquets found in specified time period.\n"
+ f"Nr of days: {days}\n"
+ f"last_modified_begin: {last_modified_begin}\n"
+ f"{repr(err)}"
+ )
+ except EmptyDataFrame as err:
+ logging.error(
+ f"Reading of data from parquets FAILED.\n"
+ f"No data in parquets in specified time period.\n"
+ f"Nr of days: {days}\n"
+ f"last_modified_begin: {last_modified_begin}\n"
+ f"{repr(err)}"
+ )
+
+ return df
+
+ def read_all_data(self, days: int=None) -> dict:
+ """Read all data necessary for all applications.
+
+ :param days: Number of days to filter. If None, all data will be
+ downloaded.
+ :type days: int
+ :returns: A dictionary where keys are names of parquets and values are
+ the pandas dataframes with fetched data.
+ :rtype: dict(str: pandas.DataFrame)
+ """
+
+ data_lists = {
+ "statistics": list(),
+ "trending": list(),
+ "iterative": list(),
+ "coverage": list()
+ }
+
+ logging.info("\n\nReading data:\n" + "-" * 13 + "\n")
+ for data_set in self._data_spec:
+ logging.info(
+ f"\n\nReading data for {data_set['data_type']} "
+ f"{data_set['partition_name']} {data_set.get('release', '')}\n"
+ )
+ schema_file = data_set.get("schema", None)
+ if schema_file:
+ try:
+ schema = pa.parquet.read_schema(
+ f"{C.PATH_TO_SCHEMAS}{schema_file}"
+ )
+ except FileNotFoundError as err:
+ logging.error(repr(err))
+ logging.error("Proceeding without schema.")
+ schema = None
+ else:
+ schema = None
+ partition_filter = lambda part: True \
+ if part[data_set["partition"]] == data_set["partition_name"] \
+ else False
+ if data_set["data_type"] in ("trending", "statistics"):
+ time_period = days
+ else:
+ time_period = None
+
+ if GENERATE_SCHEMA:
+ # Generate schema:
+ Data._write_parquet_schema(
+ path=data_set["path"],
+ partition_filter=partition_filter,
+ columns=data_set.get("columns", None),
+ days=time_period
+ )
+ return
+
+ # Read data:
+ data = Data._create_dataframe_from_parquet(
+ path=data_set["path"],
+ partition_filter=partition_filter,
+ columns=data_set.get("columns", None),
+ days=time_period,
+ schema=schema
+ )
+ if data_set["data_type"] in ("iterative", "coverage"):
+ data["release"] = data_set["release"]
+ data["release"] = data["release"].astype("category")
+
+ data_lists[data_set["data_type"]].append(data)
+
+ logging.info(
+ "\n\nData post-processing, validation and summary:\n" +
+ "-" * 45 + "\n"
+ )
+ for key in self._data.keys():
+ logging.info(f"\n\nDataframe {key}:\n")
+ self._data[key] = pd.concat(
+ data_lists[key],
+ ignore_index=True,
+ copy=False
+ )
+ self._data[key].info(verbose=True, memory_usage="deep")
+ err_msg = self._validate_columns(key)
+ if err_msg:
+ self._data[key] = pd.DataFrame()
+ logging.error(
+ f"Data validation FAILED.\n"
+ f"{err_msg}\n"
+ "Generated dataframe replaced by an empty dataframe."
+ )
+
+ mem_alloc = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1000
+ logging.info(f"\n\nMemory allocation: {mem_alloc:.0f}MB\n")
+
+ return self._data
diff --git a/csit.infra.dash/app/cdash/data/data.yaml b/csit.infra.dash/app/cdash/data/data.yaml
new file mode 100644
index 0000000000..ed5fc0b269
--- /dev/null
+++ b/csit.infra.dash/app/cdash/data/data.yaml
@@ -0,0 +1,488 @@
+- data_type: statistics
+ partition: stats_type
+ partition_name: sra
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/stats
+ schema: statistics
+ columns:
+ - job
+ - build
+ - start_time
+ - duration
+- data_type: trending
+ partition: test_type
+ partition_name: mrr
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/trending
+ schema: trending_mrr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - hosts
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_receive_rate_rate_avg
+ - result_receive_rate_rate_stdev
+ - result_receive_rate_rate_unit
+ - result_receive_rate_bandwidth_avg
+ - result_receive_rate_bandwidth_stdev
+ - result_receive_rate_bandwidth_unit
+ - telemetry
+- data_type: trending
+ partition: test_type
+ partition_name: ndrpdr
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/trending
+ schema: trending_ndrpdr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - hosts
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_pdr_lower_rate_unit
+ - result_pdr_lower_rate_value
+ - result_ndr_lower_rate_unit
+ - result_ndr_lower_rate_value
+ - result_pdr_lower_bandwidth_unit
+ - result_pdr_lower_bandwidth_value
+ - result_ndr_lower_bandwidth_unit
+ - result_ndr_lower_bandwidth_value
+ - result_latency_reverse_pdr_90_hdrh
+ - result_latency_reverse_pdr_50_hdrh
+ - result_latency_reverse_pdr_10_hdrh
+ - result_latency_reverse_pdr_0_hdrh
+ - result_latency_forward_pdr_90_hdrh
+ - result_latency_forward_pdr_50_avg
+ - result_latency_forward_pdr_50_hdrh
+ - result_latency_forward_pdr_50_unit
+ - result_latency_forward_pdr_10_hdrh
+ - result_latency_forward_pdr_0_hdrh
+ - telemetry
+- data_type: trending
+ partition: test_type
+ partition_name: hoststack
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/trending
+ schema: trending_hoststack
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - hosts
+ - tg_type
+ - result_bandwidth_unit
+ - result_bandwidth_value
+ - result_rate_unit
+ - result_rate_value
+ - result_latency_unit
+ - result_latency_value
+ - start_time
+ - passed
+ - telemetry
+ - test_id
+ - version
+- data_type: trending
+ partition: test_type
+ partition_name: soak
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/trending
+ schema: trending_soak
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - hosts
+ - tg_type
+ - result_critical_rate_lower_bandwidth_unit
+ - result_critical_rate_lower_bandwidth_value
+ - result_critical_rate_lower_rate_unit
+ - result_critical_rate_lower_rate_value
+ - start_time
+ - passed
+ - telemetry
+ - test_id
+ - version
+- data_type: iterative
+ partition: test_type
+ partition_name: mrr
+ release: rls2306
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2306
+ schema: iterative_rls2306_mrr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_receive_rate_rate_avg
+ - result_receive_rate_rate_stdev
+ - result_receive_rate_rate_unit
+ - result_receive_rate_rate_values
+- data_type: iterative
+ partition: test_type
+ partition_name: ndrpdr
+ release: rls2306
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2306
+ schema: iterative_rls2306_ndrpdr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_pdr_lower_rate_unit
+ - result_pdr_lower_rate_value
+ - result_ndr_lower_rate_unit
+ - result_ndr_lower_rate_value
+ - result_latency_reverse_pdr_90_hdrh
+ - result_latency_reverse_pdr_50_hdrh
+ - result_latency_reverse_pdr_10_hdrh
+ - result_latency_reverse_pdr_0_hdrh
+ - result_latency_forward_pdr_90_hdrh
+ - result_latency_forward_pdr_50_avg
+ - result_latency_forward_pdr_50_hdrh
+ - result_latency_forward_pdr_50_unit
+ - result_latency_forward_pdr_10_hdrh
+ - result_latency_forward_pdr_0_hdrh
+- data_type: iterative
+ partition: test_type
+ partition_name: hoststack
+ release: rls2306
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2306
+ schema: iterative_rls2306_hoststack
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - tg_type
+ - result_bandwidth_unit
+ - result_bandwidth_value
+ - result_rate_unit
+ - result_rate_value
+ - start_time
+ - passed
+ - test_id
+ - version
+- data_type: coverage
+ partition: test_type
+ partition_name: ndrpdr
+ release: rls2306
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/coverage_rls2306
+ schema: coverage_rls2306_ndrpdr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - tg_type
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_pdr_lower_rate_unit
+ - result_pdr_lower_rate_value
+ - result_ndr_lower_rate_value
+ - result_pdr_lower_bandwidth_value
+ - result_ndr_lower_bandwidth_value
+ - result_latency_reverse_pdr_90_hdrh
+ - result_latency_reverse_pdr_50_hdrh
+ - result_latency_reverse_pdr_10_hdrh
+ - result_latency_forward_pdr_90_hdrh
+ - result_latency_forward_pdr_50_hdrh
+ - result_latency_forward_pdr_10_hdrh
+- data_type: coverage
+ partition: test_type
+ partition_name: device
+ release: rls2306
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/coverage_rls2306
+ schema: coverage_rls2306_device
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - passed
+ - test_id
+ - version
+- data_type: iterative
+ partition: test_type
+ partition_name: mrr
+ release: rls2310
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2310
+ schema: iterative_rls2310_mrr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_receive_rate_rate_avg
+ - result_receive_rate_rate_stdev
+ - result_receive_rate_rate_unit
+ - result_receive_rate_rate_values
+- data_type: iterative
+ partition: test_type
+ partition_name: ndrpdr
+ release: rls2310
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2310
+ schema: iterative_rls2310_ndrpdr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_pdr_lower_rate_unit
+ - result_pdr_lower_rate_value
+ - result_ndr_lower_rate_unit
+ - result_ndr_lower_rate_value
+ - result_pdr_lower_bandwidth_unit
+ - result_pdr_lower_bandwidth_value
+ - result_ndr_lower_bandwidth_unit
+ - result_ndr_lower_bandwidth_value
+ - result_latency_reverse_pdr_90_hdrh
+ - result_latency_reverse_pdr_50_hdrh
+ - result_latency_reverse_pdr_10_hdrh
+ - result_latency_reverse_pdr_0_hdrh
+ - result_latency_forward_pdr_90_hdrh
+ - result_latency_forward_pdr_50_avg
+ - result_latency_forward_pdr_50_hdrh
+ - result_latency_forward_pdr_50_unit
+ - result_latency_forward_pdr_10_hdrh
+ - result_latency_forward_pdr_0_hdrh
+- data_type: iterative
+ partition: test_type
+ partition_name: hoststack
+ release: rls2310
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2310
+ schema: iterative_rls2310_hoststack
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - tg_type
+ - result_bandwidth_unit
+ - result_bandwidth_value
+ - result_rate_unit
+ - result_rate_value
+ - start_time
+ - passed
+ - test_id
+ - version
+- data_type: coverage
+ partition: test_type
+ partition_name: ndrpdr
+ release: rls2310
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/coverage_rls2310
+ schema: coverage_rls2310_ndrpdr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - tg_type
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_pdr_lower_rate_unit
+ - result_pdr_lower_rate_value
+ - result_ndr_lower_rate_value
+ - result_pdr_lower_bandwidth_value
+ - result_ndr_lower_bandwidth_value
+ - result_latency_reverse_pdr_90_hdrh
+ - result_latency_reverse_pdr_50_hdrh
+ - result_latency_reverse_pdr_10_hdrh
+ - result_latency_forward_pdr_90_hdrh
+ - result_latency_forward_pdr_50_hdrh
+ - result_latency_forward_pdr_10_hdrh
+- data_type: coverage
+ partition: test_type
+ partition_name: mrr
+ release: rls2310
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/coverage_rls2310
+ schema: iterative_rls2310_mrr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_receive_rate_rate_avg
+ - result_receive_rate_rate_stdev
+ - result_receive_rate_rate_unit
+- data_type: coverage
+ partition: test_type
+ partition_name: device
+ release: rls2310
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/coverage_rls2310
+ schema: coverage_rls2310_device
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - passed
+ - test_id
+ - version
+- data_type: iterative
+ partition: test_type
+ partition_name: mrr
+ release: rls2402
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2402
+ schema: iterative_rls2402_mrr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ # - hosts
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_receive_rate_rate_avg
+ - result_receive_rate_rate_stdev
+ - result_receive_rate_rate_unit
+ - result_receive_rate_rate_values
+ - result_receive_rate_bandwidth_avg
+ - result_receive_rate_bandwidth_stdev
+ - result_receive_rate_bandwidth_unit
+ - result_receive_rate_bandwidth_values
+- data_type: iterative
+ partition: test_type
+ partition_name: ndrpdr
+ release: rls2402
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2402
+ schema: iterative_rls2402_ndrpdr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ # - hosts
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_pdr_lower_rate_unit
+ - result_pdr_lower_rate_value
+ - result_ndr_lower_rate_unit
+ - result_ndr_lower_rate_value
+ - result_pdr_lower_bandwidth_unit
+ - result_pdr_lower_bandwidth_value
+ - result_ndr_lower_bandwidth_unit
+ - result_ndr_lower_bandwidth_value
+ - result_latency_reverse_pdr_90_hdrh
+ - result_latency_reverse_pdr_50_hdrh
+ - result_latency_reverse_pdr_10_hdrh
+ - result_latency_reverse_pdr_0_hdrh
+ - result_latency_forward_pdr_90_hdrh
+ - result_latency_forward_pdr_50_avg
+ - result_latency_forward_pdr_50_hdrh
+ - result_latency_forward_pdr_50_unit
+ - result_latency_forward_pdr_10_hdrh
+ - result_latency_forward_pdr_0_hdrh
+- data_type: iterative
+ partition: test_type
+ partition_name: hoststack
+ release: rls2402
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/iterative_rls2402
+ schema: iterative_rls2402_hoststack
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - hosts
+ - tg_type
+ - result_bandwidth_unit
+ - result_bandwidth_value
+ - result_rate_unit
+ - result_rate_value
+ - start_time
+ - passed
+ - test_id
+ - version
+- data_type: coverage
+ partition: test_type
+ partition_name: ndrpdr
+ release: rls2402
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/coverage_rls2402
+ schema: coverage_rls2402_ndrpdr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - tg_type
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_pdr_lower_rate_unit
+ - result_pdr_lower_rate_value
+ - result_ndr_lower_rate_value
+ - result_pdr_lower_bandwidth_value
+ - result_ndr_lower_bandwidth_value
+ - result_latency_reverse_pdr_90_hdrh
+ - result_latency_reverse_pdr_50_hdrh
+ - result_latency_reverse_pdr_10_hdrh
+ - result_latency_forward_pdr_90_hdrh
+ - result_latency_forward_pdr_50_hdrh
+ - result_latency_forward_pdr_10_hdrh
+- data_type: coverage
+ partition: test_type
+ partition_name: mrr
+ release: rls2402
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/coverage_rls2402
+ schema: iterative_rls2402_mrr
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - start_time
+ - passed
+ - test_id
+ - version
+ - result_receive_rate_rate_avg
+ - result_receive_rate_rate_stdev
+ - result_receive_rate_rate_unit
+- data_type: coverage
+ partition: test_type
+ partition_name: device
+ release: rls2402
+ path: s3://fdio-docs-s3-cloudfront-index/csit/parquet/coverage_rls2402
+ schema: coverage_rls2402_device
+ columns:
+ - job
+ - build
+ - dut_type
+ - dut_version
+ - passed
+ - test_id
+ - version
diff --git a/csit.infra.dash/app/cdash/jumpavg b/csit.infra.dash/app/cdash/jumpavg
new file mode 120000
index 0000000000..5c858f1ba2
--- /dev/null
+++ b/csit.infra.dash/app/cdash/jumpavg
@@ -0,0 +1 @@
+../../../resources/libraries/python/jumpavg/ \ No newline at end of file
diff --git a/csit.infra.dash/app/cdash/news/__init__.py b/csit.infra.dash/app/cdash/news/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/news/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/news/layout.py b/csit.infra.dash/app/cdash/news/layout.py
new file mode 100644
index 0000000000..ba4fc85163
--- /dev/null
+++ b/csit.infra.dash/app/cdash/news/layout.py
@@ -0,0 +1,508 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Plotly Dash HTML layout override.
+"""
+
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from flask import Flask
+from dash import dcc
+from dash import html
+from dash import callback_context
+from dash import Input, Output, State
+
+from ..utils.constants import Constants as C
+from ..utils.utils import gen_new_url, navbar_trending
+from ..utils.anomalies import classify_anomalies
+from ..utils.url_processing import url_decode
+from .tables import table_summary
+
+
+class Layout:
+ """The layout of the dash app and the callbacks.
+ """
+
+ def __init__(
+ self,
+ app: Flask,
+ data_stats: pd.DataFrame,
+ data_trending: pd.DataFrame,
+ html_layout_file: str
+ ) -> None:
+ """Initialization:
+ - save the input parameters,
+ - read and pre-process the data,
+ - prepare data for the control panel,
+ - read HTML layout file,
+ - read tooltips from the tooltip file.
+
+ :param app: Flask application running the dash application.
+ :param data_stats: Pandas dataframe with staistical data.
+ :param data_trending: Pandas dataframe with trending data.
+ :param html_layout_file: Path and name of the file specifying the HTML
+ layout of the dash application.
+ :type app: Flask
+ :type data_stats: pandas.DataFrame
+ :type data_trending: pandas.DataFrame
+ :type html_layout_file: str
+ """
+
+ # Inputs
+ self._app = app
+ self._html_layout_file = html_layout_file
+
+ # Prepare information for the control panel:
+ self._jobs = sorted(list(data_trending["job"].unique()))
+ d_job_info = {
+ "job": list(),
+ "dut": list(),
+ "ttype": list(),
+ "cadence": list(),
+ "tbed": list()
+ }
+ for job in self._jobs:
+ lst_job = job.split("-")
+ d_job_info["job"].append(job)
+ d_job_info["dut"].append(lst_job[1])
+ d_job_info["ttype"].append(lst_job[3])
+ d_job_info["cadence"].append(lst_job[4])
+ d_job_info["tbed"].append("-".join(lst_job[-2:]))
+ self.job_info = pd.DataFrame.from_dict(d_job_info)
+
+ # Pre-process the data:
+
+ def _create_test_name(test: str) -> str:
+ lst_tst = test.split(".")
+ suite = lst_tst[-2].replace("2n1l-", "").replace("1n1l-", "").\
+ replace("2n-", "")
+ return f"{suite.split('-')[0]}-{lst_tst[-1]}"
+
+ def _get_rindex(array: list, itm: any) -> int:
+ return len(array) - 1 - array[::-1].index(itm)
+
+ tst_info = {
+ "job": list(),
+ "build": list(),
+ "start": list(),
+ "dut_type": list(),
+ "dut_version": list(),
+ "hosts": list(),
+ "failed": list(),
+ "regressions": list(),
+ "progressions": list()
+ }
+ for job in self._jobs:
+ # Create lists of failed tests:
+ df_job = data_trending.loc[(data_trending["job"] == job)]
+ last_build = str(max(pd.to_numeric(df_job["build"].unique())))
+ df_build = df_job.loc[(df_job["build"] == last_build)]
+ tst_info["job"].append(job)
+ tst_info["build"].append(last_build)
+ tst_info["start"].append(data_stats.loc[
+ (data_stats["job"] == job) &
+ (data_stats["build"] == last_build)
+ ]["start_time"].iloc[-1].strftime('%Y-%m-%d %H:%M'))
+ tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
+ tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
+ tst_info["hosts"].append(df_build["hosts"].iloc[-1])
+ failed_tests = df_build.loc[(df_build["passed"] == False)]\
+ ["test_id"].to_list()
+ l_failed = list()
+ try:
+ for tst in failed_tests:
+ l_failed.append(_create_test_name(tst))
+ except KeyError:
+ l_failed = list()
+ tst_info["failed"].append(sorted(l_failed))
+
+ # Create lists of regressions and progressions:
+ l_reg = list()
+ l_prog = list()
+
+ tests = df_job["test_id"].unique()
+ for test in tests:
+ tst_data = df_job.loc[(
+ (df_job["test_id"] == test) &
+ (df_job["passed"] == True)
+ )].sort_values(by="start_time", ignore_index=True)
+ if "-ndrpdr" in test:
+ tst_data = tst_data.dropna(
+ subset=["result_pdr_lower_rate_value", ]
+ )
+ if tst_data.empty:
+ continue
+ x_axis = tst_data["start_time"].tolist()
+ try:
+ anomalies, _, _ = classify_anomalies({
+ k: v for k, v in zip(
+ x_axis,
+ tst_data["result_ndr_lower_rate_value"].tolist()
+ )
+ })
+ except ValueError:
+ continue
+ if "progression" in anomalies:
+ l_prog.append((
+ _create_test_name(test).replace("-ndrpdr", "-ndr"),
+ x_axis[_get_rindex(anomalies, "progression")]
+ ))
+ if "regression" in anomalies:
+ l_reg.append((
+ _create_test_name(test).replace("-ndrpdr", "-ndr"),
+ x_axis[_get_rindex(anomalies, "regression")]
+ ))
+ try:
+ anomalies, _, _ = classify_anomalies({
+ k: v for k, v in zip(
+ x_axis,
+ tst_data["result_pdr_lower_rate_value"].tolist()
+ )
+ })
+ except ValueError:
+ continue
+ if "progression" in anomalies:
+ l_prog.append((
+ _create_test_name(test).replace("-ndrpdr", "-pdr"),
+ x_axis[_get_rindex(anomalies, "progression")]
+ ))
+ if "regression" in anomalies:
+ l_reg.append((
+ _create_test_name(test).replace("-ndrpdr", "-pdr"),
+ x_axis[_get_rindex(anomalies, "regression")]
+ ))
+ else: # mrr, hoststack, soak
+ if "soak" in test:
+ val = "result_critical_rate_lower_rate_value"
+ elif "hoststack" in test:
+ val = "result_rate_value"
+ else: # mrr
+ val = "result_receive_rate_rate_avg"
+ tst_data = tst_data.dropna(subset=[val, ])
+ if tst_data.empty:
+ continue
+ x_axis = tst_data["start_time"].tolist()
+ try:
+ anomalies, _, _ = classify_anomalies({
+ k: v for k, v in zip(x_axis, tst_data[val].tolist())
+ })
+ except ValueError:
+ continue
+ if "progression" in anomalies:
+ l_prog.append((
+ _create_test_name(test),
+ x_axis[_get_rindex(anomalies, "progression")]
+ ))
+ if "regression" in anomalies:
+ l_reg.append((
+ _create_test_name(test),
+ x_axis[_get_rindex(anomalies, "regression")]
+ ))
+
+ tst_info["regressions"].append(
+ sorted(l_reg, key=lambda k: k[1], reverse=True))
+ tst_info["progressions"].append(
+ sorted(l_prog, key=lambda k: k[1], reverse=True))
+
+ self._data = pd.DataFrame.from_dict(tst_info)
+
+ # Read from files:
+ self._html_layout = str()
+
+ try:
+ with open(self._html_layout_file, "r") as file_read:
+ self._html_layout = file_read.read()
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._html_layout_file}\n{err}"
+ )
+
+ self._default_period = C.NEWS_SHORT
+ self._default_active = (False, True, False)
+
+ # Callbacks:
+ if self._app is not None and hasattr(self, 'callbacks'):
+ self.callbacks(self._app)
+
+ @property
+ def html_layout(self) -> dict:
+ return self._html_layout
+
+ def add_content(self):
+ """Top level method which generated the web page.
+
+ It generates:
+ - Store for user input data,
+ - Navigation bar,
+ - Main area with control panel and ploting area.
+
+ If no HTML layout is provided, an error message is displayed instead.
+
+ :returns: The HTML div with the whole page.
+ :rtype: html.Div
+ """
+
+ if self.html_layout:
+ return html.Div(
+ id="div-main",
+ className="small",
+ children=[
+ dcc.Location(id="url", refresh=False),
+ dbc.Row(
+ id="row-navbar",
+ class_name="g-0",
+ children=[navbar_trending((False, True, False, False))]
+ ),
+ dbc.Row(
+ id="row-main",
+ class_name="g-0",
+ children=[
+ self._add_ctrl_col(),
+ self._add_plotting_col()
+ ]
+ ),
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-documentation",
+ title="Documentation",
+ placement="end",
+ is_open=False,
+ children=html.Iframe(
+ src=C.URL_DOC_TRENDING,
+ width="100%",
+ height="100%"
+ )
+ )
+ ]
+ )
+ else:
+ return html.Div(
+ id="div-main-error",
+ children=[
+ dbc.Alert(
+ [
+ "An Error Occured"
+ ],
+ color="danger"
+ )
+ ]
+ )
+
+ def _add_ctrl_col(self) -> dbc.Col:
+ """Add column with control panel. It is placed on the left side.
+
+ :returns: Column with the control panel.
+ :rtype: dbc.Col
+ """
+ return dbc.Col([
+ html.Div(
+ children=self._add_ctrl_panel(),
+ className="sticky-top"
+ )
+ ])
+
+ def _add_plotting_col(self) -> dbc.Col:
+ """Add column with tables. It is placed on the right side.
+
+ :returns: Column with tables.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(
+ id="col-plotting-area",
+ children=[
+ dbc.Spinner(
+ children=[
+ dbc.Row(
+ id="plotting-area",
+ class_name="g-0 p-0",
+ children=[
+ C.PLACEHOLDER
+ ]
+ )
+ ]
+ )
+ ],
+ width=9
+ )
+
+ def _add_ctrl_panel(self) -> list:
+ """Add control panel.
+
+ :returns: Control panel.
+ :rtype: list
+ """
+ return [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.ButtonGroup(
+ id="bg-time-period",
+ children=[
+ dbc.Button(
+ id="period-last",
+ children="Last Run",
+ className="me-1",
+ outline=True,
+ color="info"
+ ),
+ dbc.Button(
+ id="period-short",
+ children=f"Last {C.NEWS_SHORT} Runs",
+ className="me-1",
+ outline=True,
+ active=True,
+ color="info"
+ ),
+ dbc.Button(
+ id="period-long",
+ children="All Runs",
+ className="me-1",
+ outline=True,
+ color="info"
+ )
+ ]
+ )
+ ]
+ )
+ ]
+
+ def _get_plotting_area(
+ self,
+ period: int,
+ url: str
+ ) -> list:
+ """Generate the plotting area with all its content.
+
+ :param period: The time period for summary tables.
+ :param url: URL to be displayed in the modal window.
+ :type period: int
+ :type url: str
+ :returns: The content of the plotting area.
+ :rtype: list
+ """
+ return [
+ dbc.Row(
+ id="row-table",
+ class_name="g-0 p-1",
+ children=table_summary(self._data, self._jobs, period)
+ ),
+ dbc.Row(
+ [
+ dbc.Col([html.Div(
+ [
+ dbc.Button(
+ id="plot-btn-url",
+ children="Show URL",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(dbc.ModalTitle("URL")),
+ dbc.ModalBody(url)
+ ],
+ id="plot-mod-url",
+ size="xl",
+ is_open=False,
+ scrollable=True
+ )
+ ],
+ className=\
+ "d-grid gap-0 d-md-flex justify-content-md-end"
+ )])
+ ],
+ class_name="g-0 p-0"
+ )
+ ]
+
+ def callbacks(self, app):
+ """Callbacks for the whole application.
+
+ :param app: The application.
+ :type app: Flask
+ """
+
+ @app.callback(
+ Output("plotting-area", "children"),
+ Output("period-last", "active"),
+ Output("period-short", "active"),
+ Output("period-long", "active"),
+ Input("url", "href"),
+ Input("period-last", "n_clicks"),
+ Input("period-short", "n_clicks"),
+ Input("period-long", "n_clicks")
+ )
+ def _update_application(href: str, *_) -> tuple:
+ """Update the application when the event is detected.
+
+ :returns: New values for web page elements.
+ :rtype: tuple
+ """
+
+ periods = {
+ "period-last": C.NEWS_LAST,
+ "period-short": C.NEWS_SHORT,
+ "period-long": C.NEWS_LONG
+ }
+ actives = {
+ "period-last": (True, False, False),
+ "period-short": (False, True, False),
+ "period-long": (False, False, True)
+ }
+
+ # Parse the url:
+ parsed_url = url_decode(href)
+ if parsed_url:
+ url_params = parsed_url["params"]
+ else:
+ url_params = None
+
+ trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
+ if trigger_id == "url" and url_params:
+ trigger_id = url_params.get("period", list())[0]
+
+ ret_val = [
+ self._get_plotting_area(
+ periods.get(trigger_id, self._default_period),
+ gen_new_url(parsed_url, {"period": trigger_id})
+ )
+ ]
+ ret_val.extend(actives.get(trigger_id, self._default_active))
+ return ret_val
+
+ @app.callback(
+ Output("plot-mod-url", "is_open"),
+ Input("plot-btn-url", "n_clicks"),
+ State("plot-mod-url", "is_open")
+ )
+ def toggle_plot_mod_url(n, is_open):
+ """Toggle the modal window with url.
+ """
+ if n:
+ return not is_open
+ return is_open
+
+ @app.callback(
+ Output("offcanvas-documentation", "is_open"),
+ Input("btn-documentation", "n_clicks"),
+ State("offcanvas-documentation", "is_open")
+ )
+ def toggle_offcanvas_documentation(n_clicks, is_open):
+ if n_clicks:
+ return not is_open
+ return is_open
diff --git a/csit.infra.dash/app/cdash/news/news.py b/csit.infra.dash/app/cdash/news/news.py
new file mode 100644
index 0000000000..747facc1bb
--- /dev/null
+++ b/csit.infra.dash/app/cdash/news/news.py
@@ -0,0 +1,56 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Instantiate the News Dash application.
+"""
+import dash
+import pandas as pd
+
+from ..utils.constants import Constants as C
+from .layout import Layout
+
+
+def init_news(
+ server,
+ data_stats: pd.DataFrame,
+ data_trending: pd.DataFrame
+ ) -> dash.Dash:
+ """Create a Plotly Dash dashboard.
+
+ :param server: Flask server.
+ :type server: Flask
+ :param data_stats: Pandas dataframe with staistical data.
+ :param data_trending: Pandas dataframe with trending data.
+ :type data_stats: pandas.DataFrame
+ :type data_trending: pandas.DataFrame
+ :returns: Dash app server.
+ :rtype: Dash
+ """
+
+ dash_app = dash.Dash(
+ server=server,
+ routes_pathname_prefix=C.NEWS_ROUTES_PATHNAME_PREFIX,
+ external_stylesheets=C.EXTERNAL_STYLESHEETS,
+ title=C.NEWS_TITLE
+ )
+
+ layout = Layout(
+ app=dash_app,
+ data_stats=data_stats,
+ data_trending=data_trending,
+ html_layout_file=C.HTML_LAYOUT_FILE
+ )
+ dash_app.index_string = layout.html_layout
+ dash_app.layout = layout.add_content()
+
+ return dash_app.server
diff --git a/csit.infra.dash/app/cdash/news/tables.py b/csit.infra.dash/app/cdash/news/tables.py
new file mode 100644
index 0000000000..1e9aefaf04
--- /dev/null
+++ b/csit.infra.dash/app/cdash/news/tables.py
@@ -0,0 +1,176 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""The tables with news.
+"""
+
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from datetime import datetime, timedelta
+
+
+def _table_info(job_data: pd.DataFrame) -> dbc.Table:
+ """Generates table with info about the job.
+
+ :param job_data: Dataframe with information about the job.
+ :type job_data: pandas.DataFrame
+ :returns: Table with job info.
+ :rtype: dbc.Table
+ """
+ return dbc.Table.from_dataframe(
+ pd.DataFrame.from_dict(
+ {
+ "Job": job_data["job"],
+ "Last Build": job_data["build"],
+ "Date": job_data["start"],
+ "DUT": job_data["dut_type"],
+ "DUT Version": job_data["dut_version"],
+ "Hosts": ", ".join(job_data["hosts"].to_list()[0])
+ }
+ ),
+ bordered=True,
+ striped=True,
+ hover=True,
+ size="sm",
+ color="info"
+ )
+
+
+def _table_failed(job_data: pd.DataFrame, failed: list) -> dbc.Table:
+ """Generates table with failed tests from the last run of the job.
+
+ :param job_data: Dataframe with information about the job.
+ :param failed: List of failed tests.
+ :type job_data: pandas.DataFrame
+ :type failed: list
+ :returns: Table with fialed tests.
+ :rtype: dbc.Table
+ """
+ return dbc.Table.from_dataframe(
+ pd.DataFrame.from_dict(
+ {
+ (
+ f"Last Failed Tests on "
+ f"{job_data['start'].values[0]} ({len(failed)})"
+ ): failed
+ }
+ ),
+ bordered=True,
+ striped=True,
+ hover=True,
+ size="sm",
+ color="danger"
+ )
+
+
+def _table_gressions(itms: dict, color: str) -> dbc.Table:
+ """Generates table with regressions.
+
+ :param itms: Dictionary with items (regressions or progressions) and their
+ last occurence.
+ :param color: Color of the table.
+ :type regressions: dict
+ :type color: str
+ :returns: The table with regressions.
+ :rtype: dbc.Table
+ """
+ return dbc.Table.from_dataframe(
+ pd.DataFrame.from_dict(itms),
+ bordered=True,
+ striped=True,
+ hover=True,
+ size="sm",
+ color=color
+ )
+
+
+def table_news(data: pd.DataFrame, job: str, period: int) -> list:
+ """Generates the tables with news:
+ 1. Falied tests from the last run
+ 2. Regressions and progressions calculated from the last C.NEWS_TIME_PERIOD
+ days.
+
+ :param data: Trending data with calculated annomalies to be displayed in the
+ tables.
+ :param job: The job name.
+ :param period: The time period (nr of days from now) taken into account.
+ :type data: pandas.DataFrame
+ :type job: str
+ :type period: int
+ :returns: List of tables.
+ :rtype: list
+ """
+
+ last_day = datetime.utcnow() - timedelta(days=period)
+ r_list = list()
+ job_data = data.loc[(data["job"] == job)]
+ r_list.append(_table_info(job_data))
+
+ failed = job_data["failed"].to_list()[0]
+ if failed:
+ r_list.append(_table_failed(job_data, failed))
+
+ title = f"Regressions in the last {period} days"
+ regressions = {title: list(), "Last Regression": list()}
+ for itm in job_data["regressions"].to_list()[0]:
+ if itm[1] < last_day:
+ break
+ regressions[title].append(itm[0])
+ regressions["Last Regression"].append(
+ itm[1].strftime('%Y-%m-%d %H:%M'))
+ if regressions["Last Regression"]:
+ r_list.append(_table_gressions(regressions, "warning"))
+
+ title = f"Progressions in the last {period} days"
+ progressions = {title: list(), "Last Progression": list()}
+ for itm in job_data["progressions"].to_list()[0]:
+ if itm[1] < last_day:
+ break
+ progressions[title].append(itm[0])
+ progressions["Last Progression"].append(
+ itm[1].strftime('%Y-%m-%d %H:%M'))
+ if progressions["Last Progression"]:
+ r_list.append(_table_gressions(progressions, "success"))
+
+ return r_list
+
+
+def table_summary(data: pd.DataFrame, jobs: list, period: int) -> list:
+ """Generates summary (failed tests, regressions and progressions) from the
+ last week.
+
+ :param data: Trending data with calculated annomalies to be displayed in the
+ tables.
+ :param jobs: List of jobs.
+ :params period: The time period for the summary table.
+ :type data: pandas.DataFrame
+ :type job: str
+ :type period: int
+ :returns: List of tables.
+ :rtype: list
+ """
+
+ return [
+ dbc.Accordion(
+ children=[
+ dbc.AccordionItem(
+ title=job,
+ children=table_news(data, job, period)
+ ) for job in jobs
+ ],
+ class_name="gy-2 p-0",
+ start_collapsed=True,
+ always_open=True
+ )
+ ]
diff --git a/csit.infra.dash/app/cdash/report/__init__.py b/csit.infra.dash/app/cdash/report/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/report/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/report/graphs.py b/csit.infra.dash/app/cdash/report/graphs.py
new file mode 100644
index 0000000000..02202ea155
--- /dev/null
+++ b/csit.infra.dash/app/cdash/report/graphs.py
@@ -0,0 +1,311 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Implementation of graphs for iterative data.
+"""
+
+import plotly.graph_objects as go
+import pandas as pd
+
+from copy import deepcopy
+from numpy import percentile
+
+from ..utils.constants import Constants as C
+from ..utils.utils import get_color, get_hdrh_latencies
+
+
+def select_iterative_data(data: pd.DataFrame, itm:dict) -> pd.DataFrame:
+ """Select the data for graphs and tables from the provided data frame.
+
+ :param data: Data frame with data for graphs and tables.
+ :param itm: Item (in this case job name) which data will be selected from
+ the input data frame.
+ :type data: pandas.DataFrame
+ :type itm: str
+ :returns: A data frame with selected data.
+ :rtype: pandas.DataFrame
+ """
+
+ phy = itm["phy"].split("-")
+ if len(phy) == 4:
+ topo, arch, nic, drv = phy
+ if drv == "dpdk":
+ drv = ""
+ else:
+ drv += "-"
+ drv = drv.replace("_", "-")
+ else:
+ return None
+
+ if itm["testtype"] in ("ndr", "pdr"):
+ test_type = "ndrpdr"
+ elif itm["testtype"] == "mrr":
+ test_type = "mrr"
+ elif itm["testtype"] == "soak":
+ test_type = "soak"
+ elif itm["area"] == "hoststack":
+ test_type = "hoststack"
+ df = data.loc[(
+ (data["release"] == itm["rls"]) &
+ (data["test_type"] == test_type) &
+ (data["passed"] == True)
+ )]
+
+ core = str() if itm["dut"] == "trex" else f"{itm['core']}"
+ ttype = "ndrpdr" if itm["testtype"] in ("ndr", "pdr") else itm["testtype"]
+ regex_test = \
+ f"^.*[.|-]{nic}.*{itm['framesize']}-{core}-{drv}{itm['test']}-{ttype}$"
+ df = df[
+ (df.job.str.endswith(f"{topo}-{arch}")) &
+ (df.dut_version.str.contains(itm["dutver"].replace(".r", "-r").\
+ replace("rls", "release"))) &
+ (df.test_id.str.contains(regex_test, regex=True))
+ ]
+
+ return df
+
+
+def graph_iterative(data: pd.DataFrame, sel: list, layout: dict,
+ normalize: bool=False, remove_outliers: bool=False) -> tuple:
+ """Generate the statistical box graph with iterative data (MRR, NDR and PDR,
+ for PDR also Latencies).
+
+ :param data: Data frame with iterative data.
+ :param sel: Selected tests.
+ :param layout: Layout of plot.ly graph.
+ :param normalize: If True, the data is normalized to CPU frequency
+ Constants.NORM_FREQUENCY.
+ :param remove_outliers: If True the outliers are removed before
+ generating the table.
+ :type data: pandas.DataFrame
+ :type sel: list
+ :type layout: dict
+ :type normalize: bool
+ :type remove_outliers: bool
+ :returns: Tuple of graphs - throughput and latency.
+ :rtype: tuple(plotly.graph_objects.Figure, plotly.graph_objects.Figure)
+ """
+
+ def get_y_values(data, y_data_max, param, norm_factor, release=str(),
+ remove_outliers=False):
+ if param == "result_receive_rate_rate_values":
+ if release == "rls2402":
+ y_vals_raw = data["result_receive_rate_rate_avg"].to_list()
+ else:
+ y_vals_raw = data[param].to_list()[0]
+ else:
+ y_vals_raw = data[param].to_list()
+ y_data = [(y * norm_factor) for y in y_vals_raw]
+
+ if remove_outliers:
+ try:
+ q1 = percentile(y_data, 25, method=C.COMP_PERCENTILE_METHOD)
+ q3 = percentile(y_data, 75, method=C.COMP_PERCENTILE_METHOD)
+ irq = q3 - q1
+ lif = q1 - C.COMP_OUTLIER_TYPE * irq
+ uif = q3 + C.COMP_OUTLIER_TYPE * irq
+ y_data = [i for i in y_data if i >= lif and i <= uif]
+ except TypeError:
+ pass
+ try:
+ y_data_max = max(max(y_data), y_data_max)
+ except TypeError:
+ y_data_max = 0
+ return y_data, y_data_max
+
+ fig_tput = None
+ fig_band = None
+ fig_lat = None
+
+ tput_traces = list()
+ y_tput_max = 0
+ y_units = set()
+
+ lat_traces = list()
+ y_lat_max = 0
+ x_lat = list()
+
+ band_traces = list()
+ y_band_max = 0
+ y_band_units = set()
+ x_band = list()
+
+ for idx, itm in enumerate(sel):
+
+ itm_data = select_iterative_data(data, itm)
+ if itm_data.empty:
+ continue
+
+ phy = itm["phy"].split("-")
+ topo_arch = f"{phy[0]}-{phy[1]}" if len(phy) == 4 else str()
+ norm_factor = (C.NORM_FREQUENCY / C.FREQUENCY[topo_arch]) \
+ if normalize else 1.0
+
+ if itm["area"] == "hoststack":
+ ttype = f"hoststack-{itm['testtype']}"
+ else:
+ ttype = itm["testtype"]
+
+ y_units.update(itm_data[C.UNIT[ttype]].unique().tolist())
+
+ y_data, y_tput_max = get_y_values(
+ itm_data,
+ y_tput_max,
+ C.VALUE_ITER[ttype],
+ norm_factor,
+ itm["rls"],
+ remove_outliers
+ )
+
+ nr_of_samples = len(y_data)
+
+ customdata = list()
+ metadata = {
+ "csit release": itm["rls"],
+ "dut": itm["dut"],
+ "dut version": itm["dutver"],
+ "infra": itm["phy"],
+ "test": (
+ f"{itm['area']}-{itm['framesize']}-{itm['core']}-"
+ f"{itm['test']}-{itm['testtype']}"
+ )
+ }
+
+ if itm["testtype"] == "mrr" and itm["rls"] in ("rls2306", "rls2310"):
+ trial_run = "trial"
+ metadata["csit-ref"] = (
+ f"{itm_data['job'].to_list()[0]}/",
+ f"{itm_data['build'].to_list()[0]}"
+ )
+ customdata = [{"metadata": metadata}, ] * nr_of_samples
+ else:
+ trial_run = "run"
+ for _, row in itm_data.iterrows():
+ metadata["csit-ref"] = f"{row['job']}/{row['build']}"
+ try:
+ metadata["hosts"] = ", ".join(row["hosts"])
+ except (KeyError, TypeError):
+ pass
+ customdata.append({"metadata": deepcopy(metadata)})
+ tput_kwargs = dict(
+ y=y_data,
+ name=(
+ f"{idx + 1}. "
+ f"({nr_of_samples:02d} "
+ f"{trial_run}{'s' if nr_of_samples > 1 else ''}) "
+ f"{itm['id']}"
+ ),
+ hoverinfo=u"y+name",
+ boxpoints="all",
+ jitter=0.3,
+ marker=dict(color=get_color(idx)),
+ customdata=customdata
+ )
+ tput_traces.append(go.Box(**tput_kwargs))
+
+ if ttype in C.TESTS_WITH_BANDWIDTH:
+ y_band, y_band_max = get_y_values(
+ itm_data,
+ y_band_max,
+ C.VALUE_ITER[f"{ttype}-bandwidth"],
+ norm_factor,
+ remove_outliers=remove_outliers
+ )
+ if not all(pd.isna(y_band)):
+ y_band_units.update(
+ itm_data[C.UNIT[f"{ttype}-bandwidth"]].unique().\
+ dropna().tolist()
+ )
+ band_kwargs = dict(
+ y=y_band,
+ name=(
+ f"{idx + 1}. "
+ f"({nr_of_samples:02d} "
+ f"run{'s' if nr_of_samples > 1 else ''}) "
+ f"{itm['id']}"
+ ),
+ hoverinfo=u"y+name",
+ boxpoints="all",
+ jitter=0.3,
+ marker=dict(color=get_color(idx)),
+ customdata=customdata
+ )
+ x_band.append(idx + 1)
+ band_traces.append(go.Box(**band_kwargs))
+
+ if ttype in C.TESTS_WITH_LATENCY:
+ y_lat, y_lat_max = get_y_values(
+ itm_data,
+ y_lat_max,
+ C.VALUE_ITER["latency"],
+ 1 / norm_factor,
+ remove_outliers=remove_outliers
+ )
+ if not all(pd.isna(y_lat)):
+ customdata = list()
+ for _, row in itm_data.iterrows():
+ hdrh = get_hdrh_latencies(
+ row,
+ f"{metadata['infra']}-{metadata['test']}"
+ )
+ metadata["csit-ref"] = f"{row['job']}/{row['build']}"
+ customdata.append({
+ "metadata": deepcopy(metadata),
+ "hdrh": hdrh
+ })
+ nr_of_samples = len(y_lat)
+ lat_kwargs = dict(
+ y=y_lat,
+ name=(
+ f"{idx + 1}. "
+ f"({nr_of_samples:02d} "
+ f"run{u's' if nr_of_samples > 1 else u''}) "
+ f"{itm['id']}"
+ ),
+ hoverinfo="all",
+ boxpoints="all",
+ jitter=0.3,
+ marker=dict(color=get_color(idx)),
+ customdata=customdata
+ )
+ x_lat.append(idx + 1)
+ lat_traces.append(go.Box(**lat_kwargs))
+
+ if tput_traces:
+ pl_tput = deepcopy(layout["plot-throughput"])
+ pl_tput["xaxis"]["tickvals"] = [i for i in range(len(sel))]
+ pl_tput["xaxis"]["ticktext"] = [str(i + 1) for i in range(len(sel))]
+ pl_tput["yaxis"]["title"] = f"Throughput [{'|'.join(sorted(y_units))}]"
+ if y_tput_max:
+ pl_tput["yaxis"]["range"] = [0, int(y_tput_max) * 1.1]
+ fig_tput = go.Figure(data=tput_traces, layout=pl_tput)
+
+ if band_traces:
+ pl_band = deepcopy(layout["plot-bandwidth"])
+ pl_band["xaxis"]["tickvals"] = [i for i in range(len(x_band))]
+ pl_band["xaxis"]["ticktext"] = x_band
+ pl_band["yaxis"]["title"] = \
+ f"Bandwidth [{'|'.join(sorted(y_band_units))}]"
+ if y_band_max:
+ pl_band["yaxis"]["range"] = [0, int(y_band_max) * 1.1]
+ fig_band = go.Figure(data=band_traces, layout=pl_band)
+
+ if lat_traces:
+ pl_lat = deepcopy(layout["plot-latency"])
+ pl_lat["xaxis"]["tickvals"] = [i for i in range(len(x_lat))]
+ pl_lat["xaxis"]["ticktext"] = x_lat
+ if y_lat_max:
+ pl_lat["yaxis"]["range"] = [0, int(y_lat_max) + 5]
+ fig_lat = go.Figure(data=lat_traces, layout=pl_lat)
+
+ return fig_tput, fig_band, fig_lat
diff --git a/csit.infra.dash/app/cdash/report/layout.py b/csit.infra.dash/app/cdash/report/layout.py
new file mode 100644
index 0000000000..f485894d78
--- /dev/null
+++ b/csit.infra.dash/app/cdash/report/layout.py
@@ -0,0 +1,1332 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Plotly Dash HTML layout override.
+"""
+
+
+import logging
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from flask import Flask
+from dash import dcc
+from dash import html
+from dash import callback_context, no_update, ALL
+from dash import Input, Output, State
+from dash.exceptions import PreventUpdate
+from yaml import load, FullLoader, YAMLError
+from ast import literal_eval
+
+from ..utils.constants import Constants as C
+from ..utils.control_panel import ControlPanel
+from ..utils.trigger import Trigger
+from ..utils.utils import show_tooltip, label, sync_checklists, gen_new_url, \
+ generate_options, get_list_group_items, navbar_report, \
+ show_iterative_graph_data
+from ..utils.url_processing import url_decode
+from .graphs import graph_iterative, select_iterative_data
+
+
+# Control panel partameters and their default values.
+CP_PARAMS = {
+ "dd-rls-val": str(),
+ "dd-dut-opt": list(),
+ "dd-dut-dis": True,
+ "dd-dut-val": str(),
+ "dd-dutver-opt": list(),
+ "dd-dutver-dis": True,
+ "dd-dutver-val": str(),
+ "dd-phy-opt": list(),
+ "dd-phy-dis": True,
+ "dd-phy-val": str(),
+ "dd-area-opt": list(),
+ "dd-area-dis": True,
+ "dd-area-val": str(),
+ "dd-test-opt": list(),
+ "dd-test-dis": True,
+ "dd-test-val": str(),
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True,
+ "cl-normalize-val": list()
+}
+
+
+class Layout:
+ """The layout of the dash app and the callbacks.
+ """
+
+ def __init__(
+ self,
+ app: Flask,
+ data_iterative: pd.DataFrame,
+ html_layout_file: str,
+ graph_layout_file: str,
+ tooltip_file: str
+ ) -> None:
+ """Initialization:
+ - save the input parameters,
+ - read and pre-process the data,
+ - prepare data for the control panel,
+ - read HTML layout file,
+ - read tooltips from the tooltip file.
+
+ :param app: Flask application running the dash application.
+ :param html_layout_file: Path and name of the file specifying the HTML
+ layout of the dash application.
+ :param graph_layout_file: Path and name of the file with layout of
+ plot.ly graphs.
+ :param tooltip_file: Path and name of the yaml file specifying the
+ tooltips.
+ :type app: Flask
+ :type html_layout_file: str
+ :type graph_layout_file: str
+ :type tooltip_file: str
+ """
+
+ # Inputs
+ self._app = app
+ self._html_layout_file = html_layout_file
+ self._graph_layout_file = graph_layout_file
+ self._tooltip_file = tooltip_file
+ self._data = data_iterative
+
+ # Get structure of tests:
+ tbs = dict()
+ cols = [
+ "job", "test_id", "test_type", "dut_version", "tg_type", "release"
+ ]
+ for _, row in self._data[cols].drop_duplicates().iterrows():
+ rls = row["release"]
+ lst_job = row["job"].split("-")
+ dut = lst_job[1]
+ d_ver = row["dut_version"]
+ tbed = "-".join(lst_job[-2:])
+ lst_test_id = row["test_id"].split(".")
+ if dut == "dpdk":
+ area = "dpdk"
+ else:
+ area = ".".join(lst_test_id[3:-2])
+ suite = lst_test_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
+ replace("2n-", "")
+ test = lst_test_id[-1]
+ nic = suite.split("-")[0]
+ for drv in C.DRIVERS:
+ if drv in test:
+ driver = drv.replace("-", "_")
+ test = test.replace(f"{drv}-", "")
+ break
+ else:
+ driver = "dpdk"
+ infra = "-".join((tbed, nic, driver))
+ lst_test = test.split("-")
+ framesize = lst_test[0]
+ core = lst_test[1] if lst_test[1] else "8C"
+ test = "-".join(lst_test[2: -1])
+
+ if tbs.get(rls, None) is None:
+ tbs[rls] = dict()
+ if tbs[rls].get(dut, None) is None:
+ tbs[rls][dut] = dict()
+ if tbs[rls][dut].get(d_ver, None) is None:
+ tbs[rls][dut][d_ver] = dict()
+ if tbs[rls][dut][d_ver].get(area, None) is None:
+ tbs[rls][dut][d_ver][area] = dict()
+ if tbs[rls][dut][d_ver][area].get(test, None) is None:
+ tbs[rls][dut][d_ver][area][test] = dict()
+ if tbs[rls][dut][d_ver][area][test].get(infra, None) is None:
+ tbs[rls][dut][d_ver][area][test][infra] = {
+ "core": list(),
+ "frame-size": list(),
+ "test-type": list()
+ }
+ tst_params = tbs[rls][dut][d_ver][area][test][infra]
+ if core.upper() not in tst_params["core"]:
+ tst_params["core"].append(core.upper())
+ if framesize.upper() not in tst_params["frame-size"]:
+ tst_params["frame-size"].append(framesize.upper())
+ if row["test_type"] == "ndrpdr":
+ if "NDR" not in tst_params["test-type"]:
+ tst_params["test-type"].extend(("NDR", "PDR", ))
+ elif row["test_type"] == "hoststack" and \
+ row["tg_type"] in ("iperf", "vpp"):
+ if "BPS" not in tst_params["test-type"]:
+ tst_params["test-type"].append("BPS")
+ elif row["test_type"] == "hoststack" and row["tg_type"] == "ab":
+ if "CPS" not in tst_params["test-type"]:
+ tst_params["test-type"].extend(("CPS", "RPS"))
+ else: # MRR, SOAK
+ if row["test_type"].upper() not in tst_params["test-type"]:
+ tst_params["test-type"].append(row["test_type"].upper())
+ self._spec_tbs = tbs
+
+ # Read from files:
+ self._html_layout = str()
+ self._graph_layout = None
+ self._tooltips = dict()
+
+ try:
+ with open(self._html_layout_file, "r") as file_read:
+ self._html_layout = file_read.read()
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._html_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._graph_layout_file, "r") as file_read:
+ self._graph_layout = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._graph_layout_file}\n"
+ f"{err}"
+ )
+ except YAMLError as err:
+ raise RuntimeError(
+ f"An error occurred while parsing the specification file "
+ f"{self._graph_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._tooltip_file, "r") as file_read:
+ self._tooltips = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ logging.warning(
+ f"Not possible to open the file {self._tooltip_file}\n{err}"
+ )
+ except YAMLError as err:
+ logging.warning(
+ f"An error occurred while parsing the specification file "
+ f"{self._tooltip_file}\n{err}"
+ )
+
+ # Callbacks:
+ if self._app is not None and hasattr(self, "callbacks"):
+ self.callbacks(self._app)
+
+ @property
+ def html_layout(self):
+ return self._html_layout
+
+ def add_content(self):
+ """Top level method which generated the web page.
+
+ It generates:
+ - Store for user input data,
+ - Navigation bar,
+ - Main area with control panel and ploting area.
+
+ If no HTML layout is provided, an error message is displayed instead.
+
+ :returns: The HTML div with the whole page.
+ :rtype: html.Div
+ """
+
+ if self.html_layout and self._spec_tbs:
+ return html.Div(
+ id="div-main",
+ className="small",
+ children=[
+ dbc.Row(
+ id="row-navbar",
+ class_name="g-0",
+ children=[navbar_report((True, False, False, False)), ]
+ ),
+ dbc.Row(
+ id="row-main",
+ class_name="g-0",
+ children=[
+ dcc.Store(id="store-selected-tests"),
+ dcc.Store(id="store-control-panel"),
+ dcc.Location(id="url", refresh=False),
+ self._add_ctrl_col(),
+ self._add_plotting_col()
+ ]
+ ),
+ dbc.Spinner(
+ dbc.Offcanvas(
+ class_name="w-50",
+ id="offcanvas-metadata",
+ title="Throughput And Latency",
+ placement="end",
+ is_open=False,
+ children=[
+ dbc.Row(id="metadata-tput-lat"),
+ dbc.Row(id="metadata-hdrh-graph")
+ ]
+ ),
+ delay_show=C.SPINNER_DELAY
+ ),
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-documentation",
+ title="Documentation",
+ placement="end",
+ is_open=False,
+ children=html.Iframe(
+ src=C.URL_DOC_REL_NOTES,
+ width="100%",
+ height="100%"
+ )
+ )
+ ]
+ )
+ else:
+ return html.Div(
+ id="div-main-error",
+ children=[
+ dbc.Alert(
+ [
+ "An Error Occured"
+ ],
+ color="danger"
+ )
+ ]
+ )
+
+ def _add_ctrl_col(self) -> dbc.Col:
+ """Add column with controls. It is placed on the left side.
+
+ :returns: Column with the control panel.
+ :rtype: dbc.Col
+ """
+ return dbc.Col([
+ html.Div(
+ children=self._add_ctrl_panel(),
+ className="sticky-top"
+ )
+ ])
+
+ def _add_plotting_col(self) -> dbc.Col:
+ """Add column with plots. It is placed on the right side.
+
+ :returns: Column with plots.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(
+ id="col-plotting-area",
+ children=[
+ dbc.Spinner(
+ children=[
+ dbc.Row(
+ id="plotting-area",
+ class_name="g-0 p-0",
+ children=[
+ C.PLACEHOLDER
+ ]
+ )
+ ]
+ )
+ ],
+ width=9
+ )
+
+ def _add_ctrl_panel(self) -> list:
+ """Add control panel.
+
+ :returns: Control panel.
+ :rtype: list
+ """
+ return [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-release",
+ "CSIT Release"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "rls"},
+ placeholder="Select a Release...",
+ options=sorted(
+ [
+ {"label": k, "value": k} \
+ for k in self._spec_tbs.keys()
+ ],
+ key=lambda d: d["label"]
+ )
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-dut",
+ "DUT"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dut"},
+ placeholder="Select a Device under Test..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-dut-ver",
+ "DUT Version"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dutver"},
+ placeholder=\
+ "Select a Version of Device under Test..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-area",
+ "Area"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "area"},
+ placeholder="Select an Area..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-test",
+ "Test"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "test"},
+ placeholder="Select a Test..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-infra",
+ "Infra"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "phy"},
+ placeholder=\
+ "Select a Physical Test Bed Topology..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ children=show_tooltip(
+ self._tooltips,
+ "help-framesize",
+ "Frame Size"
+ )
+ ),
+ dbc.Col(
+ children=[
+ dbc.Checklist(
+ id={
+ "type": "ctrl-cl",
+ "index": "frmsize-all"
+ },
+ options=C.CL_ALL_DISABLED,
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ width=2
+ ),
+ dbc.Col(
+ children=[
+ dbc.Checklist(
+ id={
+ "type": "ctrl-cl",
+ "index": "frmsize"
+ },
+ inline=True
+ )
+ ]
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ children=show_tooltip(
+ self._tooltips,
+ "help-cores",
+ "Number of Cores"
+ )
+ ),
+ dbc.Col(
+ children=[
+ dbc.Checklist(
+ id={
+ "type": "ctrl-cl",
+ "index": "core-all"
+ },
+ options=C.CL_ALL_DISABLED,
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ width=2
+ ),
+ dbc.Col(
+ children=[
+ dbc.Checklist(
+ id={
+ "type": "ctrl-cl",
+ "index": "core"
+ },
+ inline=True
+ )
+ ]
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ children=show_tooltip(
+ self._tooltips,
+ "help-ttype",
+ "Test Type"
+ )
+ ),
+ dbc.Col(
+ children=[
+ dbc.Checklist(
+ id={
+ "type": "ctrl-cl",
+ "index": "tsttype-all"
+ },
+ options=C.CL_ALL_DISABLED,
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ width=2
+ ),
+ dbc.Col(
+ children=[
+ dbc.Checklist(
+ id={
+ "type": "ctrl-cl",
+ "index": "tsttype"
+ },
+ inline=True
+ )
+ ]
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ children=show_tooltip(
+ self._tooltips,
+ "help-normalize",
+ "Normalization"
+ )
+ ),
+ dbc.Col(
+ children=[
+ dbc.Checklist(
+ id="normalize",
+ options=[{
+ "value": "normalize",
+ "label": (
+ "Normalize to CPU frequency "
+ "2GHz"
+ )
+ }],
+ value=[],
+ inline=True,
+ class_name="ms-2"
+ )
+ ]
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.Button(
+ id={"type": "ctrl-btn", "index": "add-test"},
+ children="Add Selected",
+ color="info"
+ )
+ ]
+ ),
+ dbc.Row(
+ id="row-card-sel-tests",
+ class_name="g-0 p-1",
+ style=C.STYLE_DISABLED,
+ children=[
+ dbc.ListGroup(
+ class_name="overflow-auto p-0",
+ id="lg-selected",
+ children=[],
+ style={"max-height": "20em"},
+ flush=True
+ )
+ ]
+ ),
+ dbc.Stack(
+ id="row-btns-sel-tests",
+ class_name="g-0 p-1",
+ style=C.STYLE_DISABLED,
+ gap=2,
+ children=[
+ dbc.ButtonGroup(children=[
+ dbc.Button(
+ id={"type": "ctrl-btn", "index": "rm-test"},
+ children="Remove Selected",
+ class_name="w-100",
+ color="info",
+ disabled=False
+ ),
+ dbc.Button(
+ id={"type": "ctrl-btn", "index": "rm-test-all"},
+ children="Remove All",
+ class_name="w-100",
+ color="info",
+ disabled=False
+ )
+ ]),
+ dbc.ButtonGroup(children=[
+ dbc.Button(
+ id="plot-btn-url",
+ children="Show URL",
+ class_name="w-100",
+ color="info",
+ disabled=False
+ ),
+ dbc.Button(
+ id="plot-btn-download",
+ children="Download Data",
+ class_name="w-100",
+ color="info",
+ disabled=False
+ )
+ ])
+ ]
+ )
+ ]
+
+ def _get_plotting_area(
+ self,
+ tests: list,
+ normalize: bool,
+ url: str
+ ) -> list:
+ """Generate the plotting area with all its content.
+
+ :param tests: List of tests to be displayed in the graphs.
+ :param normalize: If true, the values in graphs are normalized.
+ :param url: URL to be displayed in the modal window.
+ :type tests: list
+ :type normalize: bool
+ :type url: str
+ :returns: List of rows with elements to be displayed in the plotting
+ area.
+ :rtype: list
+ """
+ if not tests:
+ return C.PLACEHOLDER
+
+ graphs = \
+ graph_iterative(self._data, tests, self._graph_layout, normalize)
+
+ if not graphs[0]:
+ return C.PLACEHOLDER
+
+ tab_items = [
+ dbc.Tab(
+ children=dcc.Graph(
+ id={"type": "graph", "index": "tput"},
+ figure=graphs[0]
+ ),
+ label="Throughput",
+ tab_id="tab-tput"
+ )
+ ]
+
+ if graphs[1]:
+ tab_items.append(
+ dbc.Tab(
+ children=dcc.Graph(
+ id={"type": "graph", "index": "bandwidth"},
+ figure=graphs[1]
+ ),
+ label="Bandwidth",
+ tab_id="tab-bandwidth"
+ )
+ )
+
+ if graphs[2]:
+ tab_items.append(
+ dbc.Tab(
+ children=dcc.Graph(
+ id={"type": "graph", "index": "lat"},
+ figure=graphs[2]
+ ),
+ label="Latency",
+ tab_id="tab-lat"
+ )
+ )
+
+ return [
+ dbc.Row(
+ dbc.Tabs(
+ children=tab_items,
+ id="tabs",
+ active_tab="tab-tput",
+ ),
+ class_name="g-0 p-0"
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(dbc.ModalTitle("URL")),
+ dbc.ModalBody(url)
+ ],
+ id="plot-mod-url",
+ size="xl",
+ is_open=False,
+ scrollable=True
+ ),
+ dcc.Download(id="download-iterative-data")
+ ]
+
+ def callbacks(self, app):
+ """Callbacks for the whole application.
+
+ :param app: The application.
+ :type app: Flask
+ """
+
+ @app.callback(
+ [
+ Output("store-control-panel", "data"),
+ Output("store-selected-tests", "data"),
+ Output("plotting-area", "children"),
+ Output("row-card-sel-tests", "style"),
+ Output("row-btns-sel-tests", "style"),
+ Output("lg-selected", "children"),
+
+ Output({"type": "ctrl-dd", "index": "rls"}, "value"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "options"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "value"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "options"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "value"),
+ Output({"type": "ctrl-dd", "index": "area"}, "options"),
+ Output({"type": "ctrl-dd", "index": "area"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "area"}, "value"),
+ Output({"type": "ctrl-dd", "index": "test"}, "options"),
+ Output({"type": "ctrl-dd", "index": "test"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "test"}, "value"),
+ Output({"type": "ctrl-cl", "index": "core"}, "options"),
+ Output({"type": "ctrl-cl", "index": "core"}, "value"),
+ Output({"type": "ctrl-cl", "index": "core-all"}, "value"),
+ Output({"type": "ctrl-cl", "index": "core-all"}, "options"),
+ Output({"type": "ctrl-cl", "index": "frmsize"}, "options"),
+ Output({"type": "ctrl-cl", "index": "frmsize"}, "value"),
+ Output({"type": "ctrl-cl", "index": "frmsize-all"}, "value"),
+ Output({"type": "ctrl-cl", "index": "frmsize-all"}, "options"),
+ Output({"type": "ctrl-cl", "index": "tsttype"}, "options"),
+ Output({"type": "ctrl-cl", "index": "tsttype"}, "value"),
+ Output({"type": "ctrl-cl", "index": "tsttype-all"}, "value"),
+ Output({"type": "ctrl-cl", "index": "tsttype-all"}, "options"),
+ Output({"type": "ctrl-btn", "index": "add-test"}, "disabled"),
+ Output("normalize", "value")
+ ],
+ [
+ State("store-control-panel", "data"),
+ State("store-selected-tests", "data"),
+ State({"type": "sel-cl", "index": ALL}, "value")
+ ],
+ [
+ Input("url", "href"),
+ Input("normalize", "value"),
+
+ Input({"type": "ctrl-dd", "index": ALL}, "value"),
+ Input({"type": "ctrl-cl", "index": ALL}, "value"),
+ Input({"type": "ctrl-btn", "index": ALL}, "n_clicks")
+ ]
+ )
+ def _update_application(
+ control_panel: dict,
+ store_sel: list,
+ lst_sel: list,
+ href: str,
+ normalize: list,
+ *_
+ ) -> tuple:
+ """Update the application when the event is detected.
+ """
+
+ ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
+ on_draw = False
+
+ # Parse the url:
+ parsed_url = url_decode(href)
+ if parsed_url:
+ url_params = parsed_url["params"]
+ else:
+ url_params = None
+
+ plotting_area = no_update
+ row_card_sel_tests = no_update
+ row_btns_sel_tests = no_update
+ lg_selected = no_update
+
+ trigger = Trigger(callback_context.triggered)
+
+ if trigger.type == "url" and url_params:
+ try:
+ store_sel = literal_eval(url_params["store_sel"][0])
+ normalize = literal_eval(url_params["norm"][0])
+ except (KeyError, IndexError, AttributeError):
+ pass
+ if store_sel:
+ row_card_sel_tests = C.STYLE_ENABLED
+ row_btns_sel_tests = C.STYLE_ENABLED
+ last_test = store_sel[-1]
+ test = self._spec_tbs[last_test["rls"]][last_test["dut"]]\
+ [last_test["dutver"]][last_test["area"]]\
+ [last_test["test"]][last_test["phy"]]
+ ctrl_panel.set({
+ "dd-rls-val": last_test["rls"],
+ "dd-dut-val": last_test["dut"],
+ "dd-dut-opt": generate_options(
+ self._spec_tbs[last_test["rls"]].keys()
+ ),
+ "dd-dut-dis": False,
+ "dd-dutver-val": last_test["dutver"],
+ "dd-dutver-opt": generate_options(
+ self._spec_tbs[last_test["rls"]]\
+ [last_test["dut"]].keys()
+ ),
+ "dd-dutver-dis": False,
+ "dd-area-val": last_test["area"],
+ "dd-area-opt": [
+ {"label": label(v), "value": v} for v in \
+ sorted(self._spec_tbs[last_test["rls"]]\
+ [last_test["dut"]]\
+ [last_test["dutver"]].keys())
+ ],
+ "dd-area-dis": False,
+ "dd-test-val": last_test["test"],
+ "dd-test-opt": generate_options(
+ self._spec_tbs[last_test["rls"]][last_test["dut"]]\
+ [last_test["dutver"]][last_test["area"]].keys()
+ ),
+ "dd-test-dis": False,
+ "dd-phy-val": last_test["phy"],
+ "dd-phy-opt": generate_options(
+ self._spec_tbs[last_test["rls"]][last_test["dut"]]\
+ [last_test["dutver"]][last_test["area"]]\
+ [last_test["test"]].keys()
+ ),
+ "dd-phy-dis": False,
+ "cl-core-opt": generate_options(test["core"]),
+ "cl-core-val": [last_test["core"].upper(), ],
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_ENABLED,
+ "cl-frmsize-opt": generate_options(test["frame-size"]),
+ "cl-frmsize-val": [last_test["framesize"].upper(), ],
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_ENABLED,
+ "cl-tsttype-opt": generate_options(test["test-type"]),
+ "cl-tsttype-val": [last_test["testtype"].upper(), ],
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_ENABLED,
+ "cl-normalize-val": normalize,
+ "btn-add-dis": False
+ })
+ on_draw = True
+ elif trigger.type == "normalize":
+ ctrl_panel.set({"cl-normalize-val": normalize})
+ on_draw = True
+ elif trigger.type == "ctrl-dd":
+ if trigger.idx == "rls":
+ try:
+ options = generate_options(
+ self._spec_tbs[trigger.value].keys()
+ )
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dd-rls-val": trigger.value,
+ "dd-dut-val": str(),
+ "dd-dut-opt": options,
+ "dd-dut-dis": disabled,
+ "dd-dutver-val": str(),
+ "dd-dutver-opt": list(),
+ "dd-dutver-dis": True,
+ "dd-phy-val": str(),
+ "dd-phy-opt": list(),
+ "dd-phy-dis": True,
+ "dd-area-val": str(),
+ "dd-area-opt": list(),
+ "dd-area-dis": True,
+ "dd-test-val": str(),
+ "dd-test-opt": list(),
+ "dd-test-dis": True,
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True
+ })
+ elif trigger.idx == "dut":
+ try:
+ rls = ctrl_panel.get("dd-rls-val")
+ dut = self._spec_tbs[rls][trigger.value]
+ options = generate_options(dut.keys())
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dd-dut-val": trigger.value,
+ "dd-dutver-val": str(),
+ "dd-dutver-opt": options,
+ "dd-dutver-dis": disabled,
+ "dd-phy-val": str(),
+ "dd-phy-opt": list(),
+ "dd-phy-dis": True,
+ "dd-area-val": str(),
+ "dd-area-opt": list(),
+ "dd-area-dis": True,
+ "dd-test-val": str(),
+ "dd-test-opt": list(),
+ "dd-test-dis": True,
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True
+ })
+ elif trigger.idx == "dutver":
+ try:
+ rls = ctrl_panel.get("dd-rls-val")
+ dut = ctrl_panel.get("dd-dut-val")
+ dutver = self._spec_tbs[rls][dut][trigger.value]
+ options = [{"label": label(v), "value": v} \
+ for v in sorted(dutver.keys())]
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dd-dutver-val": trigger.value,
+ "dd-area-val": str(),
+ "dd-area-opt": options,
+ "dd-area-dis": disabled,
+ "dd-test-val": str(),
+ "dd-test-opt": list(),
+ "dd-test-dis": True,
+ "dd-phy-val": str(),
+ "dd-phy-opt": list(),
+ "dd-phy-dis": True,
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True
+ })
+ elif trigger.idx == "area":
+ try:
+ rls = ctrl_panel.get("dd-rls-val")
+ dut = ctrl_panel.get("dd-dut-val")
+ dutver = ctrl_panel.get("dd-dutver-val")
+ area = self._spec_tbs[rls][dut][dutver][trigger.value]
+ options = generate_options(area.keys())
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dd-area-val": trigger.value,
+ "dd-test-val": str(),
+ "dd-test-opt": options,
+ "dd-test-dis": disabled,
+ "dd-phy-val": str(),
+ "dd-phy-opt": list(),
+ "dd-phy-dis": True,
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True
+ })
+ elif trigger.idx == "test":
+ try:
+ rls = ctrl_panel.get("dd-rls-val")
+ dut = ctrl_panel.get("dd-dut-val")
+ dutver = ctrl_panel.get("dd-dutver-val")
+ area = ctrl_panel.get("dd-area-val")
+ test = self._spec_tbs[rls][dut][dutver][area]\
+ [trigger.value]
+ options = generate_options(test.keys())
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dd-test-val": trigger.value,
+ "dd-phy-val": str(),
+ "dd-phy-opt": options,
+ "dd-phy-dis": disabled,
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True
+ })
+ elif trigger.idx == "phy":
+ rls = ctrl_panel.get("dd-rls-val")
+ dut = ctrl_panel.get("dd-dut-val")
+ dutver = ctrl_panel.get("dd-dutver-val")
+ area = ctrl_panel.get("dd-area-val")
+ test = ctrl_panel.get("dd-test-val")
+ if all((rls, dut, dutver, area, test, trigger.value, )):
+ phy = self._spec_tbs[rls][dut][dutver][area][test]\
+ [trigger.value]
+ ctrl_panel.set({
+ "dd-phy-val": trigger.value,
+ "cl-core-opt": generate_options(phy["core"]),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_ENABLED,
+ "cl-frmsize-opt": \
+ generate_options(phy["frame-size"]),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_ENABLED,
+ "cl-tsttype-opt": \
+ generate_options(phy["test-type"]),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_ENABLED,
+ "btn-add-dis": True
+ })
+ elif trigger.type == "ctrl-cl":
+ param = trigger.idx.split("-")[0]
+ if "-all" in trigger.idx:
+ c_sel, c_all, c_id = list(), trigger.value, "all"
+ else:
+ c_sel, c_all, c_id = trigger.value, list(), str()
+ val_sel, val_all = sync_checklists(
+ options=ctrl_panel.get(f"cl-{param}-opt"),
+ sel=c_sel,
+ all=c_all,
+ id=c_id
+ )
+ ctrl_panel.set({
+ f"cl-{param}-val": val_sel,
+ f"cl-{param}-all-val": val_all,
+ })
+ if all((ctrl_panel.get("cl-core-val"),
+ ctrl_panel.get("cl-frmsize-val"),
+ ctrl_panel.get("cl-tsttype-val"), )):
+ ctrl_panel.set({"btn-add-dis": False})
+ else:
+ ctrl_panel.set({"btn-add-dis": True})
+ elif trigger.type == "ctrl-btn":
+ on_draw = True
+ if trigger.idx == "add-test":
+ rls = ctrl_panel.get("dd-rls-val")
+ dut = ctrl_panel.get("dd-dut-val")
+ dutver = ctrl_panel.get("dd-dutver-val")
+ phy = ctrl_panel.get("dd-phy-val")
+ area = ctrl_panel.get("dd-area-val")
+ test = ctrl_panel.get("dd-test-val")
+ # Add selected test to the list of tests in store:
+ if store_sel is None:
+ store_sel = list()
+ for core in ctrl_panel.get("cl-core-val"):
+ for framesize in ctrl_panel.get("cl-frmsize-val"):
+ for ttype in ctrl_panel.get("cl-tsttype-val"):
+ if dut == "trex":
+ core = str()
+ tid = "-".join((
+ rls,
+ dut,
+ dutver,
+ phy.replace("af_xdp", "af-xdp"),
+ area,
+ framesize.lower(),
+ core.lower(),
+ test,
+ ttype.lower()
+ ))
+ if tid not in [i["id"] for i in store_sel]:
+ store_sel.append({
+ "id": tid,
+ "rls": rls,
+ "dut": dut,
+ "dutver": dutver,
+ "phy": phy,
+ "area": area,
+ "test": test,
+ "framesize": framesize.lower(),
+ "core": core.lower(),
+ "testtype": ttype.lower()
+ })
+ store_sel = sorted(store_sel, key=lambda d: d["id"])
+ if C.CLEAR_ALL_INPUTS:
+ ctrl_panel.set(ctrl_panel.defaults)
+ elif trigger.idx == "rm-test" and lst_sel:
+ new_store_sel = list()
+ for idx, item in enumerate(store_sel):
+ if not lst_sel[idx]:
+ new_store_sel.append(item)
+ store_sel = new_store_sel
+ elif trigger.idx == "rm-test-all":
+ store_sel = list()
+
+ if on_draw:
+ if store_sel:
+ lg_selected = get_list_group_items(
+ store_sel, "sel-cl", add_index=True
+ )
+ plotting_area = self._get_plotting_area(
+ store_sel,
+ bool(normalize),
+ gen_new_url(
+ parsed_url,
+ {"store_sel": store_sel, "norm": normalize}
+ )
+ )
+ row_card_sel_tests = C.STYLE_ENABLED
+ row_btns_sel_tests = C.STYLE_ENABLED
+ else:
+ plotting_area = C.PLACEHOLDER
+ row_card_sel_tests = C.STYLE_DISABLED
+ row_btns_sel_tests = C.STYLE_DISABLED
+ store_sel = list()
+
+ ret_val = [
+ ctrl_panel.panel,
+ store_sel,
+ plotting_area,
+ row_card_sel_tests,
+ row_btns_sel_tests,
+ lg_selected
+ ]
+ ret_val.extend(ctrl_panel.values)
+ return ret_val
+
+ @app.callback(
+ Output("plot-mod-url", "is_open"),
+ Output("plot-btn-url", "n_clicks"),
+ Input("plot-btn-url", "n_clicks"),
+ State("plot-mod-url", "is_open")
+ )
+ def toggle_plot_mod_url(n, is_open):
+ """Toggle the modal window with url.
+ """
+ if n:
+ return not is_open, 0
+ return is_open, 0
+
+ @app.callback(
+ Output("download-iterative-data", "data"),
+ State("store-selected-tests", "data"),
+ Input("plot-btn-download", "n_clicks"),
+ prevent_initial_call=True
+ )
+ def _download_iterative_data(store_sel, _):
+ """Download the data
+
+ :param store_sel: List of tests selected by user stored in the
+ browser.
+ :type store_sel: list
+ :returns: dict of data frame content (base64 encoded) and meta data
+ used by the Download component.
+ :rtype: dict
+ """
+
+ if not store_sel:
+ raise PreventUpdate
+
+ df = pd.DataFrame()
+ for itm in store_sel:
+ sel_data = select_iterative_data(self._data, itm)
+ if sel_data is None:
+ continue
+ df = pd.concat([df, sel_data], ignore_index=True)
+
+ return dcc.send_data_frame(df.to_csv, C.REPORT_DOWNLOAD_FILE_NAME)
+
+ @app.callback(
+ Output("metadata-tput-lat", "children"),
+ Output("metadata-hdrh-graph", "children"),
+ Output("offcanvas-metadata", "is_open"),
+ Input({"type": "graph", "index": ALL}, "clickData"),
+ prevent_initial_call=True
+ )
+ def _show_metadata_from_graphs(graph_data: dict) -> tuple:
+ """Generates the data for the offcanvas displayed when a particular
+ point in a graph is clicked on.
+
+ :param graph_data: The data from the clicked point in the graph.
+ :type graph_data: dict
+ :returns: The data to be displayed on the offcanvas and the
+ information to show the offcanvas.
+ :rtype: tuple(list, list, bool)
+ """
+
+ trigger = Trigger(callback_context.triggered)
+ if not trigger.value:
+ raise PreventUpdate
+
+ return show_iterative_graph_data(
+ trigger, graph_data, self._graph_layout)
+
+ @app.callback(
+ Output("offcanvas-documentation", "is_open"),
+ Input("btn-documentation", "n_clicks"),
+ State("offcanvas-documentation", "is_open")
+ )
+ def toggle_offcanvas_documentation(n_clicks, is_open):
+ if n_clicks:
+ return not is_open
+ return is_open
diff --git a/csit.infra.dash/app/cdash/report/layout.yaml b/csit.infra.dash/app/cdash/report/layout.yaml
new file mode 100644
index 0000000000..dea50144c2
--- /dev/null
+++ b/csit.infra.dash/app/cdash/report/layout.yaml
@@ -0,0 +1,156 @@
+plot-throughput:
+ xaxis:
+ title: "Test Cases [Index]"
+ autorange: True
+ fixedrange: False
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickmode: "array"
+ zeroline: True
+ yaxis:
+ title: "Throughput [pps|cps|rps|bps]"
+ gridcolor: "rgb(230, 230, 230)"
+ hoverformat: ".3s"
+ tickformat: ".3s"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ zeroline: True
+ range: [0, 100]
+ autosize: True
+ margin:
+ t: 50
+ b: 0
+ l: 80
+ r: 20
+ showlegend: False
+ height: 850
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-bandwidth:
+ xaxis:
+ title: "Test Cases [Index]"
+ autorange: True
+ fixedrange: False
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickmode: "array"
+ zeroline: True
+ yaxis:
+ title: "Bandwidth [bps]"
+ gridcolor: "rgb(230, 230, 230)"
+ hoverformat: ".3s"
+ tickformat: ".3s"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ zeroline: True
+ range: [0, 200]
+ autosize: True
+ margin:
+ t: 50
+ b: 0
+ l: 80
+ r: 20
+ showlegend: False
+ height: 850
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-latency:
+ xaxis:
+ title: "Test Cases [Index]"
+ autorange: True
+ fixedrange: False
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickmode: "array"
+ zeroline: True
+ yaxis:
+ title: "Average Latency at 50% PDR [us]"
+ gridcolor: "rgb(230, 230, 230)"
+ hoverformat: ".3s"
+ tickformat: ".3s"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ zeroline: True
+ range: [0, 200]
+ autosize: True
+ margin:
+ t: 50
+ b: 0
+ l: 80
+ r: 20
+ showlegend: False
+ height: 850
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-hdrh-latency:
+ showlegend: True
+ legend:
+ traceorder: "normal"
+ orientation: "h"
+ xanchor: "left"
+ yanchor: "top"
+ x: 0
+ y: -0.25
+ bgcolor: "rgba(255, 255, 255, 0)"
+ bordercolor: "rgba(255, 255, 255, 0)"
+ xaxis:
+ type: "log"
+ title: "Percentile [%]"
+ autorange: True
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickvals: [1, 2, 1e1, 20, 1e2, 1e3, 1e4, 1e5, 1e6]
+ ticktext: [0, 50, 90, 95, 99, 99.9, 99.99, 99.999, 99.9999]
+ yaxis:
+ title: "One-Way Latency per Direction [us]"
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ autosize: True
+ paper_bgcolor: "white"
+ plot_bgcolor: "white"
diff --git a/csit.infra.dash/app/cdash/report/report.py b/csit.infra.dash/app/cdash/report/report.py
new file mode 100644
index 0000000000..ce5e977f4c
--- /dev/null
+++ b/csit.infra.dash/app/cdash/report/report.py
@@ -0,0 +1,52 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Instantiate the Report Dash application.
+"""
+import dash
+import pandas as pd
+
+from ..utils.constants import Constants as C
+from .layout import Layout
+
+
+def init_report(
+ server,
+ data_iterative: pd.DataFrame
+ ) -> dash.Dash:
+ """Create a Plotly Dash dashboard.
+
+ :param server: Flask server.
+ :type server: Flask
+ :returns: Dash app server.
+ :rtype: Dash
+ """
+
+ dash_app = dash.Dash(
+ server=server,
+ routes_pathname_prefix=C.REPORT_ROUTES_PATHNAME_PREFIX,
+ external_stylesheets=C.EXTERNAL_STYLESHEETS,
+ title=C.REPORT_TITLE
+ )
+
+ layout = Layout(
+ app=dash_app,
+ data_iterative=data_iterative,
+ html_layout_file=C.HTML_LAYOUT_FILE,
+ graph_layout_file=C.REPORT_GRAPH_LAYOUT_FILE,
+ tooltip_file=C.TOOLTIP_FILE
+ )
+ dash_app.index_string = layout.html_layout
+ dash_app.layout = layout.add_content()
+
+ return dash_app.server
diff --git a/csit.infra.dash/app/cdash/routes.py b/csit.infra.dash/app/cdash/routes.py
new file mode 100644
index 0000000000..ed29fffa12
--- /dev/null
+++ b/csit.infra.dash/app/cdash/routes.py
@@ -0,0 +1,38 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Routes for parent Flask app.
+"""
+
+from flask import current_app as app
+from flask import render_template
+
+from .utils.constants import Constants as C
+
+
+@app.route(C.APPLICATIN_ROOT)
+def home():
+ """Landing page.
+ """
+ return render_template(
+ C.MAIN_HTML_LAYOUT_FILE,
+ title=C.TITLE,
+ description=C.DESCRIPTION,
+ trending_title=C.TREND_TITLE,
+ report_title=C.REPORT_TITLE,
+ comp_title=C.COMP_TITLE,
+ stats_title=C.STATS_TITLE,
+ news_title=C.NEWS_TITLE,
+ cov_title=C.COVERAGE_TITLE,
+ search_title=C.SEARCH_TITLE
+ )
diff --git a/csit.infra.dash/app/cdash/search/__init__.py b/csit.infra.dash/app/cdash/search/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/search/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/search/layout.py b/csit.infra.dash/app/cdash/search/layout.py
new file mode 100644
index 0000000000..aa4dd53d5b
--- /dev/null
+++ b/csit.infra.dash/app/cdash/search/layout.py
@@ -0,0 +1,948 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+"""Plotly Dash HTML layout override.
+"""
+
+import logging
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from flask import Flask
+from dash import dcc
+from dash import html, dash_table
+from dash import callback_context, no_update, ALL
+from dash import Input, Output, State
+from dash.exceptions import PreventUpdate
+from yaml import load, FullLoader, YAMLError
+from ast import literal_eval
+
+from ..utils.constants import Constants as C
+from ..utils.control_panel import ControlPanel
+from ..utils.trigger import Trigger
+from ..utils.utils import gen_new_url, generate_options, navbar_trending, \
+ filter_table_data, sort_table_data, show_trending_graph_data, \
+ show_iterative_graph_data, show_tooltip
+from ..utils.url_processing import url_decode
+from .tables import search_table
+from ..coverage.tables import coverage_tables
+from ..report.graphs import graph_iterative
+from ..trending.graphs import graph_trending
+
+
+# Control panel partameters and their default values.
+CP_PARAMS = {
+ "datatype-val": str(),
+ "dut-opt": list(),
+ "dut-dis": C.STYLE_DONT_DISPLAY,
+ "dut-val": str(),
+ "release-opt": list(),
+ "release-dis": C.STYLE_DONT_DISPLAY,
+ "release-val": str(),
+ "help-dis": C.STYLE_DONT_DISPLAY,
+ "help-val": str(),
+ "search-dis": C.STYLE_DONT_DISPLAY,
+ "search-val": str()
+}
+
+
+class Layout:
+ """The layout of the dash app and the callbacks.
+ """
+
+ def __init__(self,
+ app: Flask,
+ data: dict,
+ html_layout_file: str,
+ graph_layout_file: str,
+ tooltip_file: str
+ ) -> None:
+ """Initialization:
+ - save the input parameters,
+ - read and pre-process the data,
+ - prepare data for the control panel,
+ - read HTML layout file,
+ - read graph layout file,
+ - read tooltips from the tooltip file.
+
+ :param app: Flask application running the dash application.
+ :param data_trending: Pandas dataframe with trending data.
+ :param html_layout_file: Path and name of the file specifying the HTML
+ layout of the dash application.
+ :param graph_layout_file: Path and name of the file with layout of
+ plot.ly graphs.
+ :param tooltip_file: Path and name of the yaml file specifying the
+ tooltips.
+ :type app: Flask
+ :type data_trending: pandas.DataFrame
+ :type html_layout_file: str
+ :type graph_layout_file: str
+ :type tooltip_file: str
+ """
+
+ # Inputs
+ self._app = app
+ self._html_layout_file = html_layout_file
+ self._graph_layout_file = graph_layout_file
+ self._tooltip_file = tooltip_file
+ # Inputs - Data
+ self._data = {
+ k: v for k, v in data.items() if not v.empty and k != "statistics"
+ }
+
+ for data_type, pd in self._data.items():
+ if pd.empty:
+ continue
+ full_id = list()
+
+ for _, row in pd.iterrows():
+ l_id = row["test_id"].split(".")
+ suite = l_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
+ replace("2n-", "")
+ tb = "-".join(row["job"].split("-")[-2:])
+ nic = suite.split("-")[0]
+ for driver in C.DRIVERS:
+ if driver in suite:
+ drv = driver
+ break
+ else:
+ drv = "dpdk"
+ test = l_id[-1]
+
+ if data_type in ("iterative", "coverage", ):
+ full_id.append(
+ "_".join((row["release"], row["dut_type"],
+ row["dut_version"], tb, nic, drv, test))
+ )
+ else: # Trending
+ full_id.append(
+ "_".join((row["dut_type"], tb, nic, drv, test))
+ )
+ pd["full_id"] = full_id
+
+ # Get structure of tests:
+ self._duts = dict()
+ for data_type, pd in self._data.items():
+ if pd.empty:
+ continue
+ self._duts[data_type] = dict()
+ if data_type in ("iterative", "coverage", ):
+ cols = ["job", "dut_type", "dut_version", "release", "test_id"]
+ for _, row in pd[cols].drop_duplicates().iterrows():
+ dut = row["dut_type"]
+ if self._duts[data_type].get(dut, None) is None:
+ self._duts[data_type][dut] = list()
+ if row["release"] not in self._duts[data_type][dut]:
+ self._duts[data_type][dut].append(row["release"])
+ else:
+ for dut in pd["dut_type"].unique():
+ if self._duts[data_type].get(dut, None) is None:
+ self._duts[data_type][dut] = list()
+
+ # Read from files:
+ self._html_layout = str()
+ self._graph_layout = None
+ self._tooltips = dict()
+
+ try:
+ with open(self._html_layout_file, "r") as file_read:
+ self._html_layout = file_read.read()
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._html_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._graph_layout_file, "r") as file_read:
+ self._graph_layout = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._graph_layout_file}\n"
+ f"{err}"
+ )
+ except YAMLError as err:
+ raise RuntimeError(
+ f"An error occurred while parsing the specification file "
+ f"{self._graph_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._tooltip_file, "r") as file_read:
+ self._tooltips = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ logging.warning(
+ f"Not possible to open the file {self._tooltip_file}\n{err}"
+ )
+ except YAMLError as err:
+ logging.warning(
+ f"An error occurred while parsing the specification file "
+ f"{self._tooltip_file}\n{err}"
+ )
+
+ # Callbacks:
+ if self._app is not None and hasattr(self, "callbacks"):
+ self.callbacks(self._app)
+
+ @property
+ def html_layout(self):
+ return self._html_layout
+
+ def add_content(self):
+ """Top level method which generated the web page.
+
+ It generates:
+ - Store for user input data,
+ - Navigation bar,
+ - Main area with control panel and ploting area.
+
+ If no HTML layout is provided, an error message is displayed instead.
+
+ :returns: The HTML div with the whole page.
+ :rtype: html.Div
+ """
+ if self.html_layout and self._duts:
+ return html.Div(
+ id="div-main",
+ className="small",
+ children=[
+ dcc.Store(id="store"),
+ dcc.Store(id="store-table-data"),
+ dcc.Store(id="store-filtered-table-data"),
+ dcc.Location(id="url", refresh=False),
+ dbc.Row(
+ id="row-navbar",
+ class_name="g-0",
+ children=[navbar_trending((False, False, False, True))]
+ ),
+ dbc.Row(
+ id="row-main",
+ class_name="g-0",
+ children=[
+ self._add_ctrl_col(),
+ self._add_plotting_col()
+ ]
+ ),
+ dbc.Spinner(
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-details",
+ title="Test Details",
+ placement="end",
+ is_open=False,
+ children=[]
+ ),
+ delay_show=C.SPINNER_DELAY
+ ),
+ dbc.Spinner(
+ dbc.Offcanvas(
+ class_name="w-50",
+ id="offcanvas-metadata",
+ title="Detailed Information",
+ placement="end",
+ is_open=False,
+ children=[
+ dbc.Row(id="metadata-tput-lat"),
+ dbc.Row(id="metadata-hdrh-graph")
+ ]
+ ),
+ delay_show=C.SPINNER_DELAY
+ ),
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-documentation",
+ title="Documentation",
+ placement="end",
+ is_open=False,
+ children=html.Iframe(
+ src=C.URL_DOC_TRENDING,
+ width="100%",
+ height="100%"
+ )
+ )
+ ]
+ )
+ else:
+ return html.Div(
+ dbc.Alert("An Error Occured", color="danger"),
+ id="div-main-error"
+ )
+
+ def _add_ctrl_col(self) -> dbc.Col:
+ """Add column with controls. It is placed on the left side.
+
+ :returns: Column with the control panel.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(html.Div(self._add_ctrl_panel(), className="sticky-top"))
+
+ def _add_ctrl_panel(self) -> list:
+ """Add control panel.
+
+ :returns: Control panel.
+ :rtype: list
+ """
+ return [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-data-type",
+ "Data Type"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "datatype"},
+ placeholder="Select a Data Type...",
+ options=sorted(
+ [
+ {"label": k, "value": k} \
+ for k in self._data.keys()
+ ],
+ key=lambda d: d["label"]
+ )
+ )
+ ],
+ size="sm"
+ )
+ ],
+ style=C.STYLE_DISPLAY
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ id={"type": "ctrl-row", "index": "dut"},
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-dut",
+ "DUT"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dut"},
+ placeholder="Select a Device under Test..."
+ )
+ ],
+ size="sm"
+ )
+ ],
+ style=C.STYLE_DONT_DISPLAY
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ id={"type": "ctrl-row", "index": "release"},
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-release",
+ "CSIT Release"
+ )),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "release"},
+ placeholder="Select a Release..."
+ )
+ ],
+ size="sm"
+ )
+ ],
+ style=C.STYLE_DONT_DISPLAY
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ id={"type": "ctrl-row", "index": "help"},
+ children=[
+ dbc.Input(
+ id={"type": "ctrl-dd", "index": "help"},
+ readonly=True,
+ debounce=True,
+ size="sm"
+ )
+ ],
+ style=C.STYLE_DONT_DISPLAY
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ id={"type": "ctrl-row", "index": "search"},
+ children=[
+ dbc.Input(
+ id={"type": "ctrl-dd", "index": "search"},
+ placeholder="Type a Regular Expression...",
+ debounce=True,
+ size="sm"
+ )
+ ],
+ style=C.STYLE_DONT_DISPLAY
+ )
+ ]
+
+ def _add_plotting_col(self) -> dbc.Col:
+ """Add column with tables. It is placed on the right side.
+
+ :returns: Column with tables.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(
+ id="col-plotting-area",
+ children=[
+ dbc.Spinner(
+ children=[
+ dbc.Row(
+ id="plotting-area",
+ class_name="g-0 p-0",
+ children=[C.PLACEHOLDER, ]
+ )
+ ]
+ )
+ ],
+ width=9
+ )
+
+ @staticmethod
+ def _get_plotting_area(table: pd.DataFrame, url: str) -> list:
+ """Generate the plotting area with all its content.
+
+ :param table: Search table to be displayed.
+ :param url: URL to be displayed in a modal window.
+ :type table: pandas.DataFrame
+ :type url: str
+ :returns: List of rows with elements to be displayed in the plotting
+ area.
+ :rtype: list
+ """
+
+ if table.empty:
+ return dbc.Row(
+ dbc.Col(
+ children=dbc.Alert(
+ "No data found.",
+ color="danger"
+ ),
+ class_name="g-0 p-1",
+ ),
+ class_name="g-0 p-0"
+ )
+
+ columns = [{"name": col, "id": col} for col in table.columns]
+
+ return [
+ dbc.Row(
+ children=[
+ dbc.Col(
+ children=dash_table.DataTable(
+ id={"type": "table", "index": "search"},
+ columns=columns,
+ data=table.to_dict("records"),
+ filter_action="custom",
+ sort_action="custom",
+ sort_mode="multi",
+ selected_columns=[],
+ selected_rows=[],
+ page_action="none",
+ style_cell={"textAlign": "left"}
+ ),
+ class_name="g-0 p-1"
+ )
+ ],
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ [
+ dbc.Col([html.Div(
+ [
+ dbc.Button(
+ id="plot-btn-url",
+ children="Show URL",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(dbc.ModalTitle("URL")),
+ dbc.ModalBody(url)
+ ],
+ id="plot-mod-url",
+ size="xl",
+ is_open=False,
+ scrollable=True
+ ),
+ dbc.Button(
+ id="plot-btn-download",
+ children="Download Data",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dcc.Download(id="download-data")
+ ],
+ className=\
+ "d-grid gap-0 d-md-flex justify-content-md-end"
+ )])
+ ],
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ children=C.PLACEHOLDER,
+ class_name="g-0 p-1"
+ )
+ ]
+
+ def callbacks(self, app):
+ """Callbacks for the whole application.
+
+ :param app: The application.
+ :type app: Flask
+ """
+
+ @app.callback(
+ Output("store", "data"),
+ Output("store-table-data", "data"),
+ Output("store-filtered-table-data", "data"),
+ Output("plotting-area", "children"),
+ Output({"type": "table", "index": ALL}, "data"),
+ Output({"type": "ctrl-dd", "index": "datatype"}, "value"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "options"),
+ Output({"type": "ctrl-row", "index": "dut"}, "style"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "value"),
+ Output({"type": "ctrl-dd", "index": "release"}, "options"),
+ Output({"type": "ctrl-row", "index": "release"}, "style"),
+ Output({"type": "ctrl-dd", "index": "release"}, "value"),
+ Output({"type": "ctrl-row", "index": "help"}, "style"),
+ Output({"type": "ctrl-dd", "index": "help"}, "value"),
+ Output({"type": "ctrl-row", "index": "search"}, "style"),
+ Output({"type": "ctrl-dd", "index": "search"}, "value"),
+ State("store", "data"),
+ State("store-table-data", "data"),
+ State("store-filtered-table-data", "data"),
+ State({"type": "table", "index": ALL}, "data"),
+ Input("url", "href"),
+ Input({"type": "table", "index": ALL}, "filter_query"),
+ Input({"type": "table", "index": ALL}, "sort_by"),
+ Input({"type": "ctrl-dd", "index": ALL}, "value"),
+ prevent_initial_call=True
+ )
+ def _update_application(
+ store: dict,
+ store_table_data: list,
+ filtered_data: list,
+ table_data: list,
+ href: str,
+ *_
+ ) -> tuple:
+ """Update the application when the event is detected.
+ """
+
+ if store is None:
+ store = {
+ "control-panel": dict(),
+ "selection": dict()
+ }
+
+ ctrl_panel = ControlPanel(
+ CP_PARAMS,
+ store.get("control-panel", dict())
+ )
+ selection = store["selection"]
+
+ plotting_area = no_update
+ on_draw = False
+
+ # Parse the url:
+ parsed_url = url_decode(href)
+ if parsed_url:
+ url_params = parsed_url["params"]
+ else:
+ url_params = None
+
+ trigger = Trigger(callback_context.triggered)
+ if trigger.type == "url" and url_params:
+ try:
+ selection = literal_eval(url_params["selection"][0])
+ if selection:
+ dtype = selection["datatype"]
+ dut = selection["dut"]
+ if dtype == "trending":
+ rls_opts = list()
+ rls_dis = C.STYLE_DONT_DISPLAY
+ else:
+ rls_opts = generate_options(self._duts[dtype][dut])
+ rls_dis = C.STYLE_DISPLAY
+ ctrl_panel.set({
+ "datatype-val": dtype,
+ "dut-opt": \
+ generate_options(self._duts[dtype].keys()),
+ "dut-dis": C.STYLE_DISPLAY,
+ "dut-val": dut,
+ "release-opt": rls_opts,
+ "release-dis": rls_dis,
+ "release-val": selection["release"],
+ "help-dis": C.STYLE_DISPLAY,
+ "help-val": selection["help"],
+ "search-dis": C.STYLE_DISPLAY,
+ "search-val": selection["regexp"]
+ })
+ on_draw = True
+ except (KeyError, IndexError, AttributeError, ValueError):
+ pass
+ elif trigger.type == "ctrl-dd":
+ if trigger.idx == "datatype":
+ try:
+ data_type = self._duts[trigger.value]
+ options = generate_options(data_type.keys())
+ disabled = C.STYLE_DISPLAY
+ except KeyError:
+ options = list()
+ disabled = C.STYLE_DONT_DISPLAY
+ ctrl_panel.set({
+ "datatype-val": trigger.value,
+ "dut-opt": options,
+ "dut-dis": disabled,
+ "dut-val": str(),
+ "release-opt": list(),
+ "release-dis": C.STYLE_DONT_DISPLAY,
+ "release-val": str(),
+ "help-dis": C.STYLE_DONT_DISPLAY,
+ "help-val": str(),
+ "search-dis": C.STYLE_DONT_DISPLAY,
+ "search-val": str()
+ })
+ elif trigger.idx == "dut":
+ try:
+ data_type = ctrl_panel.get("datatype-val")
+ dut = self._duts[data_type][trigger.value]
+ if data_type != "trending":
+ options = generate_options(dut)
+ disabled = C.STYLE_DISPLAY
+ except KeyError:
+ options = list()
+ disabled = C.STYLE_DONT_DISPLAY
+ if data_type == "trending":
+ ctrl_panel.set({
+ "dut-val": trigger.value,
+ "release-opt": list(),
+ "release-dis": C.STYLE_DONT_DISPLAY,
+ "release-val": str(),
+ "help-dis": disabled,
+ "help-val": "<testbed> <nic> <driver> " + \
+ "<framesize> <cores> <test>",
+ "search-dis": disabled,
+ "search-val": str()
+ })
+ else:
+ ctrl_panel.set({
+ "dut-val": trigger.value,
+ "release-opt": options,
+ "release-dis": disabled,
+ "release-val": str(),
+ "help-dis": C.STYLE_DONT_DISPLAY,
+ "help-val": str(),
+ "search-dis": C.STYLE_DONT_DISPLAY,
+ "search-val": str()
+ })
+ elif trigger.idx == "release":
+ ctrl_panel.set({
+ "release-val": trigger.value,
+ "help-dis": C.STYLE_DISPLAY,
+ "help-val": "<DUT version> <testbed> <nic> " + \
+ "<driver> <framesize> <core> <test>",
+ "search-dis": C.STYLE_DISPLAY,
+ "search-val": str()
+ })
+ elif trigger.idx == "search":
+ ctrl_panel.set({"search-val": trigger.value})
+ selection = {
+ "datatype": ctrl_panel.get("datatype-val"),
+ "dut": ctrl_panel.get("dut-val"),
+ "release": ctrl_panel.get("release-val"),
+ "help": ctrl_panel.get("help-val"),
+ "regexp": ctrl_panel.get("search-val"),
+ }
+ on_draw = True
+ elif trigger.type == "table" and trigger.idx == "search":
+ if trigger.parameter == "filter_query":
+ filtered_data = filter_table_data(
+ store_table_data,
+ trigger.value
+ )
+ elif trigger.parameter == "sort_by":
+ filtered_data = sort_table_data(
+ store_table_data,
+ trigger.value
+ )
+ table_data = [filtered_data, ]
+
+ if on_draw:
+ table = search_table(data=self._data, selection=selection)
+ plotting_area = Layout._get_plotting_area(
+ table,
+ gen_new_url(parsed_url, {"selection": selection})
+ )
+ store_table_data = table.to_dict("records")
+ filtered_data = store_table_data
+ if table_data:
+ table_data = [store_table_data, ]
+ else:
+ plotting_area = no_update
+
+ store["control-panel"] = ctrl_panel.panel
+ store["selection"] = selection
+ ret_val = [
+ store,
+ store_table_data,
+ filtered_data,
+ plotting_area,
+ table_data
+ ]
+ ret_val.extend(ctrl_panel.values)
+
+ return ret_val
+
+ @app.callback(
+ Output("offcanvas-details", "is_open"),
+ Output("offcanvas-details", "children"),
+ State("store", "data"),
+ State("store-filtered-table-data", "data"),
+ Input({"type": "table", "index": ALL}, "active_cell"),
+ prevent_initial_call=True
+ )
+ def show_test_data(store, table, *_):
+ """Show offcanvas with graphs and tables based on selected test(s).
+ """
+
+ trigger = Trigger(callback_context.triggered)
+ if not trigger.value:
+ raise PreventUpdate
+
+ try:
+ row = pd.DataFrame.from_records(table).\
+ iloc[[trigger.value["row"]]]
+ datatype = store["selection"]["datatype"]
+ dut = store["selection"]["dut"]
+ rls = store["selection"]["release"]
+ tb = row["Test Bed"].iloc[0]
+ nic = row["NIC"].iloc[0]
+ driver = row["Driver"].iloc[0]
+ test_name = row["Test"].iloc[0]
+ dutver = str()
+ except(KeyError, IndexError, AttributeError, ValueError):
+ raise PreventUpdate
+
+ data = self._data[datatype]
+ if datatype == "trending":
+ df = pd.DataFrame(data.loc[data["dut_type"] == dut])
+ else:
+ dutver = row["DUT Version"].iloc[0]
+ df = pd.DataFrame(data.loc[(
+ (data["dut_type"] == dut) &
+ (data["dut_version"] == dutver) &
+ (data["release"] == rls)
+ )])
+
+ df = df[df.full_id.str.contains(
+ f".*{tb}.*{nic}.*{test_name}",
+ regex=True
+ )]
+
+ if datatype in ("trending", "iterative"):
+ l_test_id = df["test_id"].iloc[0].split(".")
+ if dut == "dpdk":
+ area = "dpdk"
+ else:
+ area = ".".join(l_test_id[3:-2])
+ for drv in C.DRIVERS:
+ if drv in test_name:
+ test = test_name.replace(f"{drv}-", "")
+ break
+ else:
+ test = test_name
+ l_test = test.split("-")
+ testtype = l_test[-1]
+ if testtype == "ndrpdr":
+ testtype = ["ndr", "pdr"]
+ else:
+ testtype = [testtype, ]
+ core = l_test[1] if l_test[1] else "8c"
+ test = "-".join(l_test[2: -1])
+ test_id = f"{tb}-{nic}-{driver}-{l_test[0]}-{core}-{test}"
+ title = dbc.Row(
+ class_name="g-0 p-0",
+ children=dbc.Alert(test_id, color="info"),
+ )
+ selected = list()
+ indexes = ("tput", "bandwidth", "lat")
+ if datatype == "trending":
+ for ttype in testtype:
+ selected.append({
+ "id": f"{dut}-{test_id}-{ttype}",
+ "dut": dut,
+ "phy": f"{tb}-{nic}-{driver}",
+ "area": area,
+ "test": test,
+ "framesize": l_test[0],
+ "core": core,
+ "testtype": ttype
+ })
+ graphs = graph_trending(df, selected, self._graph_layout)
+ labels = ("Throughput", "Bandwidth", "Latency")
+ tabs = list()
+ for graph, label, idx in zip(graphs, labels, indexes):
+ if graph:
+ tabs.append(dbc.Tab(
+ children=dcc.Graph(
+ figure=graph,
+ id={"type": "graph-trend", "index": idx},
+ ),
+ label=label
+ ))
+ if tabs:
+ ret_val = [
+ title,
+ dbc.Row(dbc.Tabs(tabs), class_name="g-0 p-0")
+ ]
+ else:
+ ret_val = [
+ title,
+ dbc.Row("No data.", class_name="g-0 p-0")
+ ]
+
+ else: # Iterative
+ for ttype in testtype:
+ selected.append({
+ "id": f"{test_id}-{ttype}",
+ "rls": rls,
+ "dut": dut,
+ "dutver": dutver,
+ "phy": f"{tb}-{nic}-{driver}",
+ "area": area,
+ "test": test,
+ "framesize": l_test[0],
+ "core": core,
+ "testtype": ttype
+ })
+ graphs = graph_iterative(df, selected, self._graph_layout)
+ cols = list()
+ for graph, idx in zip(graphs, indexes):
+ if graph:
+ cols.append(dbc.Col(dcc.Graph(
+ figure=graph,
+ id={"type": "graph-iter", "index": idx},
+ )))
+ if not cols:
+ cols="No data."
+ ret_val = [
+ title,
+ dbc.Row(class_name="g-0 p-0", children=cols)
+ ]
+
+ elif datatype == "coverage":
+ ret_val = coverage_tables(
+ data=df,
+ selected={
+ "rls": rls,
+ "dut": dut,
+ "dutver": dutver,
+ "phy": f"{tb}-{nic}-{driver}",
+ "area": ".*",
+ },
+ start_collapsed=False
+ )
+ else:
+ raise PreventUpdate
+
+ return True, ret_val
+
+ @app.callback(
+ Output("metadata-tput-lat", "children"),
+ Output("metadata-hdrh-graph", "children"),
+ Output("offcanvas-metadata", "is_open"),
+ Input({"type": "graph-trend", "index": ALL}, "clickData"),
+ Input({"type": "graph-iter", "index": ALL}, "clickData"),
+ prevent_initial_call=True
+ )
+ def _show_metadata_from_graph(
+ trend_data: dict,
+ iter_data: dict
+ ) -> tuple:
+ """Generates the data for the offcanvas displayed when a particular
+ point in a graph is clicked on.
+ """
+
+ trigger = Trigger(callback_context.triggered)
+ if not trigger.value:
+ raise PreventUpdate
+
+ if trigger.type == "graph-trend":
+ return show_trending_graph_data(
+ trigger, trend_data, self._graph_layout)
+ elif trigger.type == "graph-iter":
+ return show_iterative_graph_data(
+ trigger, iter_data, self._graph_layout)
+ else:
+ raise PreventUpdate
+
+ @app.callback(
+ Output("plot-mod-url", "is_open"),
+ Input("plot-btn-url", "n_clicks"),
+ State("plot-mod-url", "is_open")
+ )
+ def toggle_plot_mod_url(n, is_open):
+ """Toggle the modal window with url.
+ """
+ if n:
+ return not is_open
+ return is_open
+
+ @app.callback(
+ Output("download-data", "data"),
+ State("store-filtered-table-data", "data"),
+ Input("plot-btn-download", "n_clicks"),
+ prevent_initial_call=True
+ )
+ def _download_search_data(selection, _):
+ """Download the data.
+
+ :param selection: Selected data in table format (records).
+ :type selection: dict
+ :returns: dict of data frame content (base64 encoded) and meta data
+ used by the Download component.
+ :rtype: dict
+ """
+
+ if not selection:
+ raise PreventUpdate
+
+ return dcc.send_data_frame(
+ pd.DataFrame.from_records(selection).to_csv,
+ C.SEARCH_DOWNLOAD_FILE_NAME
+ )
+
+ @app.callback(
+ Output("offcanvas-documentation", "is_open"),
+ Input("btn-documentation", "n_clicks"),
+ State("offcanvas-documentation", "is_open")
+ )
+ def toggle_offcanvas_documentation(n_clicks, is_open):
+ if n_clicks:
+ return not is_open
+ return is_open
diff --git a/csit.infra.dash/app/cdash/search/layout.yaml b/csit.infra.dash/app/cdash/search/layout.yaml
new file mode 100644
index 0000000000..7d86e53f67
--- /dev/null
+++ b/csit.infra.dash/app/cdash/search/layout.yaml
@@ -0,0 +1,276 @@
+plot-throughput:
+ xaxis:
+ title: "Test Cases [Index]"
+ autorange: True
+ fixedrange: False
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickmode: "array"
+ zeroline: True
+ yaxis:
+ title: "Throughput [pps|cps|rps|bps]"
+ gridcolor: "rgb(230, 230, 230)"
+ hoverformat: ".3s"
+ tickformat: ".3s"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ zeroline: True
+ range: [0, 100]
+ autosize: True
+ margin:
+ t: 50
+ b: 0
+ l: 80
+ r: 20
+ showlegend: False
+ height: 850
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-bandwidth:
+ xaxis:
+ title: "Test Cases [Index]"
+ autorange: True
+ fixedrange: False
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickmode: "array"
+ zeroline: True
+ yaxis:
+ title: "Bandwidth [bps]"
+ gridcolor: "rgb(230, 230, 230)"
+ hoverformat: ".3s"
+ tickformat: ".3s"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ zeroline: True
+ range: [0, 200]
+ autosize: True
+ margin:
+ t: 50
+ b: 0
+ l: 80
+ r: 20
+ showlegend: False
+ height: 850
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-latency:
+ xaxis:
+ title: "Test Cases [Index]"
+ autorange: True
+ fixedrange: False
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickmode: "array"
+ zeroline: True
+ yaxis:
+ title: "Average Latency at 50% PDR [us]"
+ gridcolor: "rgb(230, 230, 230)"
+ hoverformat: ".3s"
+ tickformat: ".3s"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ zeroline: True
+ range: [0, 200]
+ autosize: True
+ margin:
+ t: 50
+ b: 0
+ l: 80
+ r: 20
+ showlegend: False
+ height: 850
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-trending-tput:
+ autosize: True
+ showlegend: False
+ yaxis:
+ showticklabels: True
+ tickformat: ".3s"
+ title: "Throughput [pps|cps|rps|bps]"
+ hoverformat: ".5s"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 0
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-trending-bandwidth:
+ autosize: True
+ showlegend: False
+ yaxis:
+ showticklabels: True
+ tickformat: ".3s"
+ title: "Bandwidth [bps]"
+ hoverformat: ".5s"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 0
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-trending-lat:
+ autosize: True
+ showlegend: False
+ yaxis:
+ showticklabels: True
+ tickformat: ".3s"
+ title: "Average Latency at 50% PDR [us]"
+ hoverformat: ".5s"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 0
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-hdrh-latency:
+ showlegend: True
+ legend:
+ traceorder: "normal"
+ orientation: "h"
+ xanchor: "left"
+ yanchor: "top"
+ x: 0
+ y: -0.25
+ bgcolor: "rgba(255, 255, 255, 0)"
+ bordercolor: "rgba(255, 255, 255, 0)"
+ xaxis:
+ type: "log"
+ title: "Percentile [%]"
+ autorange: True
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickvals: [1, 2, 1e1, 20, 1e2, 1e3, 1e4, 1e5, 1e6]
+ ticktext: [0, 50, 90, 95, 99, 99.9, 99.99, 99.999, 99.9999]
+ yaxis:
+ title: "One-Way Latency per Direction [us]"
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ autosize: True
+ paper_bgcolor: "white"
+ plot_bgcolor: "white"
diff --git a/csit.infra.dash/app/cdash/search/search.py b/csit.infra.dash/app/cdash/search/search.py
new file mode 100644
index 0000000000..0ecdcb7f82
--- /dev/null
+++ b/csit.infra.dash/app/cdash/search/search.py
@@ -0,0 +1,52 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Instantiate the Search Dash application.
+"""
+
+import dash
+
+from ..utils.constants import Constants as C
+from .layout import Layout
+
+
+def init_search(
+ server,
+ data: tuple
+ ) -> dash.Dash:
+ """Create a Plotly Dash dashboard.
+
+ :param server: Flask server.
+ :type server: Flask
+ :returns: Dash app server.
+ :rtype: Dash
+ """
+
+ dash_app = dash.Dash(
+ server=server,
+ routes_pathname_prefix=C.SEARCH_ROUTES_PATHNAME_PREFIX,
+ external_stylesheets=C.EXTERNAL_STYLESHEETS,
+ title=C.SEARCH_TITLE
+ )
+
+ layout = Layout(
+ app=dash_app,
+ data=data,
+ html_layout_file=C.HTML_LAYOUT_FILE,
+ graph_layout_file=C.SEARCH_GRAPH_LAYOUT_FILE,
+ tooltip_file=C.TOOLTIP_FILE
+ )
+ dash_app.index_string = layout.html_layout
+ dash_app.layout = layout.add_content()
+
+ return dash_app.server
diff --git a/csit.infra.dash/app/cdash/search/tables.py b/csit.infra.dash/app/cdash/search/tables.py
new file mode 100644
index 0000000000..a5ffd76d3d
--- /dev/null
+++ b/csit.infra.dash/app/cdash/search/tables.py
@@ -0,0 +1,123 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""The search data tables.
+"""
+
+
+import pandas as pd
+
+from ..utils.constants import Constants as C
+
+
+def select_search_data(data: pd.DataFrame, selection: list) -> pd.DataFrame:
+ """Return the searched data based on the user's "selection".
+
+ :param data: Input data to be searched through.
+ :param selection: User selection.
+ :type data: pandas.DataFrame
+ :type selection: list[dict]
+ :returns: A dataframe with selected tests.
+ :trype: pandas.DataFrame
+ """
+
+ sel_data = data[selection["datatype"]]
+
+ if selection["datatype"] == "trending":
+ df = pd.DataFrame(sel_data.loc[
+ sel_data["dut_type"] == selection["dut"]
+ ])
+ else:
+ df = pd.DataFrame(sel_data.loc[(
+ (sel_data["dut_type"] == selection["dut"]) &
+ (sel_data["release"] == selection["release"])
+ )])
+ try:
+ df = df[
+ df.full_id.str.contains(
+ selection["regexp"].replace(" ", ".*"),
+ regex=True
+ )
+ ]
+ except Exception:
+ return pd.DataFrame()
+
+ return df
+
+
+def search_table(data: pd.DataFrame, selection: list) -> pd.DataFrame:
+ """Generate a table listing tests based on user's selection.
+
+ :param data: Input data (all tests).
+ :param selection: User selection.
+ :type data: pandas.DataFrame
+ :type selection: list[dict]
+ :returns: A dataframe with selected tests/
+ :rtype: pandas.DataFrame
+ """
+
+ sel = select_search_data(data, selection)
+ if sel.empty:
+ return pd.DataFrame()
+
+ l_tb, l_nic, l_drv, l_test, = list(), list(), list(), list()
+ if selection["datatype"] == "trending":
+ cols = ["job", "test_id"]
+ else:
+ l_dutver = list()
+ cols = ["job", "test_id", "dut_version"]
+ for _, row in sel[cols].drop_duplicates().iterrows():
+ l_id = row["test_id"].split(".")
+ suite = l_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
+ replace("2n-", "")
+ l_tb.append("-".join(row["job"].split("-")[-2:]))
+ l_nic.append(suite.split("-")[0])
+ if selection["datatype"] != "trending":
+ l_dutver.append(row["dut_version"])
+ for driver in C.DRIVERS:
+ if driver in suite:
+ l_drv.append(driver)
+ break
+ else:
+ l_drv.append("dpdk")
+ l_test.append(l_id[-1])
+
+ if selection["datatype"] == "trending":
+ selected = pd.DataFrame.from_dict({
+ "Test Bed": l_tb,
+ "NIC": l_nic,
+ "Driver": l_drv,
+ "Test": l_test
+ })
+
+ selected.sort_values(
+ by=["Test Bed", "NIC", "Driver", "Test"],
+ ascending=True,
+ inplace=True
+ )
+ else:
+ selected = pd.DataFrame.from_dict({
+ "DUT Version": l_dutver,
+ "Test Bed": l_tb,
+ "NIC": l_nic,
+ "Driver": l_drv,
+ "Test": l_test
+ })
+
+ selected.sort_values(
+ by=["DUT Version", "Test Bed", "NIC", "Driver", "Test"],
+ ascending=True,
+ inplace=True
+ )
+
+ return selected
diff --git a/csit.infra.dash/app/cdash/static/dist/img/favicon.svg b/csit.infra.dash/app/cdash/static/dist/img/favicon.svg
new file mode 100644
index 0000000000..689757e3fd
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/dist/img/favicon.svg
@@ -0,0 +1,348 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1000 568.31" style="enable-background:new 0 0 1000 568.31;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#1DCAD3;}
+ .st1{fill:#36B0C9;}
+ .st2{fill:#231F20;}
+ .st3{fill:#FFFFFF;}
+ .st4{fill:#9164CC;}
+ .st5{clip-path:url(#SVGID_2_);fill:url(#SVGID_3_);}
+ .st6{fill:#201747;}
+ .st7{fill-rule:evenodd;clip-rule:evenodd;fill:#10CFC9;}
+ .st8{clip-path:url(#SVGID_5_);fill:#231F20;}
+ .st9{fill-rule:evenodd;clip-rule:evenodd;fill:#231F20;}
+ .st10{clip-path:url(#SVGID_7_);fill:#FFFFFF;}
+ .st11{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+ .st12{fill:#8CCEAF;}
+ .st13{fill:#008476;}
+ .st14{fill:#25BCBD;}
+ .st15{fill:#004D70;}
+ .st16{fill:#20BBBB;}
+ .st17{fill:#024D70;}
+ .st18{fill-rule:evenodd;clip-rule:evenodd;fill:#F58B1F;}
+ .st19{fill-rule:evenodd;clip-rule:evenodd;fill:#18335B;}
+ .st20{clip-path:url(#SVGID_9_);}
+ .st21{clip-path:url(#SVGID_11_);}
+ .st22{fill:#18335B;}
+ .st23{fill:#F58B1F;}
+ .st24{clip-path:url(#SVGID_15_);}
+ .st25{clip-path:url(#SVGID_17_);}
+ .st26{clip-path:url(#SVGID_21_);}
+ .st27{clip-path:url(#SVGID_23_);}
+ .st28{clip-path:url(#SVGID_27_);}
+ .st29{clip-path:url(#SVGID_29_);}
+ .st30{clip-path:url(#SVGID_33_);}
+ .st31{clip-path:url(#SVGID_35_);}
+ .st32{clip-path:url(#SVGID_39_);}
+ .st33{clip-path:url(#SVGID_41_);}
+ .st34{fill:#416BA9;}
+ .st35{fill:#73C3D5;}
+ .st36{opacity:0.8;}
+ .st37{fill:#3A3A3A;}
+ .st38{fill:url(#SVGID_44_);}
+ .st39{fill:none;stroke:#000000;stroke-width:6.3384;}
+ .st40{fill:none;stroke:#000000;stroke-width:3.1692;}
+ .st41{fill:#48494B;}
+ .st42{fill:#C1986C;}
+ .st43{fill:url(#SVGID_63_);}
+ .st44{fill:url(#SVGID_64_);}
+ .st45{fill:url(#SVGID_65_);}
+ .st46{fill:url(#SVGID_66_);}
+ .st47{fill:url(#SVGID_67_);}
+ .st48{fill:#4D4E4E;}
+ .st49{fill:#27B373;}
+ .st50{fill:#5DC4CD;}
+ .st51{fill:#1E8756;}
+ .st52{fill:#3D1152;}
+ .st53{fill:#922C48;}
+ .st54{fill-rule:evenodd;clip-rule:evenodd;fill:#922C48;}
+ .st55{fill:#404041;}
+ .st56{fill:#EC1C24;}
+ .st57{fill:#373A36;}
+ .st58{fill:#808184;}
+ .st59{fill:#262261;}
+ .st60{fill:#6FCBDC;}
+ .st61{fill:#2F3436;}
+ .st62{fill:#5F97D0;}
+ .st63{fill:#132428;}
+ .st64{fill:#85C041;}
+ .st65{fill:#677784;}
+ .st66{fill:url(#SVGID_68_);}
+ .st67{opacity:0.2;clip-path:url(#SVGID_70_);}
+ .st68{fill:#FFFEFA;}
+ .st69{opacity:0.1;}
+ .st70{fill:url(#SVGID_71_);}
+ .st71{opacity:0.3;}
+ .st72{opacity:0.08;}
+ .st73{opacity:0.1;fill:url(#Wordmark_1_);}
+ .st74{fill:url(#SVGID_104_);}
+ .st75{opacity:0.6;fill:url(#SVGID_107_);}
+ .st76{opacity:0.4;}
+ .st77{fill:url(#SVGID_110_);}
+ .st78{opacity:0.6;fill:url(#SVGID_113_);}
+ .st79{fill:url(#SVGID_116_);}
+ .st80{opacity:0.6;fill:url(#SVGID_119_);}
+ .st81{fill:url(#SVGID_122_);}
+ .st82{opacity:0.6;fill:url(#SVGID_125_);}
+ .st83{fill:url(#SVGID_128_);}
+ .st84{opacity:0.6;fill:url(#SVGID_131_);}
+ .st85{fill:#221F1F;}
+ .st86{fill:none;}
+ .st87{fill:#00416B;}
+ .st88{opacity:0.8;fill:url(#XMLID_323_);}
+ .st89{fill:#4197CB;}
+ .st90{fill:#003E52;}
+ .st91{fill:#3F96B4;}
+ .st92{fill:#B9DBE5;}
+ .st93{opacity:0.3;fill:#231F20;}
+ .st94{opacity:0.3;fill:#FFFFFF;}
+ .st95{fill:#050013;}
+ .st96{fill:#E87200;}
+ .st97{fill:#FCB813;}
+ .st98{fill:#3D3935;}
+ .st99{fill:#FFB600;}
+ .st100{fill:#FCB814;}
+ .st101{fill:#F48120;}
+ .st102{fill:#EF4E25;}
+ .st103{fill:#ED3024;}
+ .st104{fill:#E0592A;}
+ .st105{fill:#00ADBB;}
+ .st106{fill:#00829B;}
+ .st107{fill:#93D500;}
+ .st108{fill:#4D5A31;}
+ .st109{fill:#6BA43A;}
+ .st110{fill:#424143;}
+ .st111{fill-rule:evenodd;clip-rule:evenodd;fill:#C7E6B4;}
+ .st112{fill-rule:evenodd;clip-rule:evenodd;fill:#5A9891;}
+ .st113{fill-rule:evenodd;clip-rule:evenodd;fill:#127870;}
+ .st114{fill-rule:evenodd;clip-rule:evenodd;fill:#5CCFD5;}
+ .st115{fill-rule:evenodd;clip-rule:evenodd;fill:#ACD5CD;}
+ .st116{fill-rule:evenodd;clip-rule:evenodd;fill:#B5ECC9;}
+ .st117{fill-rule:evenodd;clip-rule:evenodd;fill:#A1D683;}
+ .st118{fill-rule:evenodd;clip-rule:evenodd;fill:#DEF0D3;}
+ .st119{fill-rule:evenodd;clip-rule:evenodd;fill:#91B9B4;}
+ .st120{fill-rule:evenodd;clip-rule:evenodd;fill:#006860;}
+ .st121{fill-rule:evenodd;clip-rule:evenodd;fill:#00ADBB;}
+ .st122{fill-rule:evenodd;clip-rule:evenodd;fill:#B4E7E9;}
+ .st123{fill-rule:evenodd;clip-rule:evenodd;fill:#007565;}
+ .st124{fill-rule:evenodd;clip-rule:evenodd;fill:#00CE7C;}
+ .st125{fill-rule:evenodd;clip-rule:evenodd;fill:#5FD896;}
+ .st126{fill:#007DA5;}
+ .st127{fill:#313032;}
+ .st128{fill:#24272A;}
+ .st129{fill:#00AFAA;}
+ .st130{fill:#66C9BA;}
+ .st131{fill:#0069A7;}
+ .st132{fill:#002F87;}
+ .st133{fill:#8BC53F;}
+ .st134{fill:#1A1A1A;}
+ .st135{fill:#0095D6;}
+ .st136{fill:#003F5F;}
+ .st137{fill:#2D317C;}
+ .st138{fill:#41BFBF;}
+ .st139{fill:#293C97;}
+ .st140{fill:#52C2BD;}
+ .st141{fill:url(#SVGID_134_);}
+ .st142{fill:url(#SVGID_135_);}
+ .st143{fill:url(#SVGID_136_);}
+ .st144{fill:#0DBEEA;}
+ .st145{fill:#097EC2;}
+ .st146{fill:#133C63;}
+ .st147{fill:#3B91CF;}
+ .st148{fill:#C8DEE8;}
+ .st149{fill:#629BBA;}
+ .st150{fill:#F8BE19;}
+ .st151{fill:url(#SVGID_137_);}
+ .st152{fill:url(#SVGID_138_);}
+ .st153{fill:url(#SVGID_139_);}
+ .st154{fill:#00233B;}
+ .st155{fill:url(#SVGID_140_);}
+ .st156{fill:url(#SVGID_141_);}
+ .st157{fill:url(#SVGID_142_);}
+ .st158{fill:url(#SVGID_143_);}
+ .st159{fill:url(#SVGID_144_);}
+ .st160{fill:url(#SVGID_145_);}
+ .st161{fill:url(#SVGID_146_);}
+ .st162{fill:url(#SVGID_147_);}
+ .st163{fill:url(#SVGID_148_);}
+ .st164{fill:url(#SVGID_149_);}
+ .st165{fill:url(#SVGID_150_);}
+ .st166{fill:url(#SVGID_151_);}
+ .st167{fill:url(#SVGID_152_);}
+ .st168{fill:url(#SVGID_153_);}
+ .st169{fill:url(#SVGID_154_);}
+ .st170{fill:url(#SVGID_155_);}
+ .st171{fill:url(#SVGID_156_);}
+ .st172{fill:url(#SVGID_157_);}
+ .st173{fill:url(#SVGID_158_);}
+ .st174{fill:url(#SVGID_159_);}
+ .st175{fill:url(#SVGID_160_);}
+ .st176{fill:url(#SVGID_161_);}
+ .st177{fill:url(#SVGID_162_);}
+ .st178{fill:url(#SVGID_163_);}
+ .st179{fill:url(#SVGID_164_);}
+ .st180{fill:url(#SVGID_165_);}
+ .st181{fill:url(#SVGID_166_);}
+ .st182{fill:url(#SVGID_167_);}
+ .st183{fill:url(#SVGID_168_);}
+ .st184{fill:url(#SVGID_169_);}
+ .st185{fill:url(#SVGID_170_);}
+ .st186{fill:url(#SVGID_171_);}
+ .st187{fill:url(#SVGID_172_);}
+ .st188{fill:url(#SVGID_173_);}
+ .st189{fill:url(#SVGID_174_);}
+ .st190{fill:url(#SVGID_175_);}
+ .st191{fill:url(#SVGID_176_);}
+ .st192{fill:url(#SVGID_177_);}
+ .st193{fill:url(#SVGID_178_);}
+ .st194{fill:#C31230;}
+ .st195{fill:#807F82;}
+ .st196{fill-rule:evenodd;clip-rule:evenodd;fill:#C31230;}
+ .st197{fill-rule:evenodd;clip-rule:evenodd;fill:#807F82;}
+ .st198{fill:#2D2D2D;}
+ .st199{display:none;fill:#2D2D2D;}
+ .st200{fill:#D11F3C;}
+ .st201{fill:#E42C4C;stroke:#E42C4C;stroke-width:1.0503;stroke-miterlimit:10;}
+ .st202{display:none;fill:#231F20;}
+ .st203{display:none;fill:#FFFFFF;}
+ .st204{fill:#FF7F30;}
+ .st205{opacity:0.3;fill:#FF7F30;}
+ .st206{opacity:0.6;fill:#FF7F30;}
+ .st207{opacity:0.7;fill:#FF7F30;}
+ .st208{fill:#221C35;}
+ .st209{fill:#1B98D5;}
+ .st210{fill:#173963;}
+ .st211{fill:#009ADE;}
+ .st212{fill:#003764;}
+ .st213{fill:#2A7DE1;}
+ .st214{opacity:0.4;clip-path:url(#XMLID_324_);fill:#221F1F;}
+ .st215{fill:#002A3A;}
+ .st216{fill:#0033A1;}
+ .st217{fill:url(#SVGID_179_);}
+ .st218{fill:url(#SVGID_180_);}
+ .st219{fill:url(#SVGID_181_);}
+ .st220{fill:url(#SVGID_182_);}
+ .st221{fill:#007EC4;}
+ .st222{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_183_);}
+ .st223{fill-rule:evenodd;clip-rule:evenodd;fill:#E6E7E8;}
+ .st224{fill:#009345;}
+ .st225{fill:#BBBCB8;}
+ .st226{fill:#72C0EB;}
+ .st227{fill:#939598;}
+ .st228{fill-rule:evenodd;clip-rule:evenodd;fill:#2CB8EB;}
+ .st229{fill:#2CB8EB;}
+ .st230{fill:#81B83A;}
+ .st231{fill-rule:evenodd;clip-rule:evenodd;fill:#81B83A;}
+ .st232{enable-background:new ;}
+ .st233{fill:#FF6F3E;}
+ .st234{fill:#12143D;}
+ .st235{fill:url(#SVGID_184_);}
+ .st236{fill:url(#SVGID_185_);}
+ .st237{fill:url(#SVGID_186_);}
+ .st238{fill:url(#SVGID_187_);}
+ .st239{fill:url(#SVGID_188_);}
+ .st240{fill:url(#SVGID_189_);}
+ .st241{fill:url(#SVGID_190_);}
+ .st242{fill:url(#SVGID_191_);}
+ .st243{fill:url(#SVGID_192_);}
+ .st244{fill:#7C51A0;}
+ .st245{fill:#9F66A9;}
+ .st246{fill:#9F80B9;}
+ .st247{fill:url(#SVGID_193_);}
+ .st248{fill:url(#SVGID_194_);}
+ .st249{fill:url(#SVGID_195_);}
+ .st250{fill:url(#SVGID_196_);}
+ .st251{fill:#2D3136;}
+ .st252{fill:#76777A;}
+ .st253{fill:#A7A8A9;}
+ .st254{fill:#0082CA;}
+ .st255{fill:#FFB259;}
+ .st256{fill:#385CAD;}
+ .st257{fill:#7BA0C4;}
+ .st258{fill:#EBA900;}
+ .st259{fill:#929497;}
+ .st260{opacity:0.7;fill:#FFFFFF;}
+ .st261{fill:#016BAF;}
+ .st262{fill:#343432;}
+ .st263{fill:#6D6E70;}
+ .st264{fill:#F4B01B;}
+ .st265{fill:#293271;}
+ .st266{fill:#A1D33C;}
+ .st267{fill:#212322;}
+ .st268{fill:#0047BA;}
+ .st269{fill:#969CDE;}
+ .st270{fill:#047BC1;}
+ .st271{fill:url(#SVGID_197_);}
+ .st272{fill:url(#SVGID_198_);}
+ .st273{fill:url(#SVGID_199_);}
+ .st274{fill:url(#SVGID_200_);}
+ .st275{fill:url(#SVGID_201_);}
+ .st276{fill:url(#SVGID_202_);}
+ .st277{fill:url(#SVGID_203_);}
+ .st278{fill:#13517C;}
+ .st279{fill:#0077A6;}
+ .st280{fill:none;stroke:#231F20;stroke-width:5.9036;stroke-miterlimit:10;}
+ .st281{fill:#00A94F;}
+ .st282{fill:none;stroke:#231F20;stroke-width:3.2172;stroke-miterlimit:10;}
+ .st283{fill:#59595C;}
+ .st284{opacity:0.349;fill:#F9AE19;}
+ .st285{opacity:0.349;fill:#E99F22;}
+ .st286{opacity:0.349;fill:#E47D25;}
+ .st287{fill:#F9AE19;}
+ .st288{fill:#E99F22;}
+ .st289{fill:#F09B20;}
+ .st290{fill:#E47D25;}
+ .st291{fill:#E89223;}
+ .st292{opacity:0.651;fill:#F9AE19;}
+ .st293{fill:#E68825;}
+ .st294{opacity:0.651;fill:#E99F22;}
+ .st295{fill:#EB8D23;}
+ .st296{opacity:0.7725;fill:#EF9B21;}
+ .st297{opacity:0.651;fill:#E47D25;}
+ .st298{opacity:0.7725;fill:#EA9622;}
+ .st299{fill:url(#SVGID_204_);}
+ .st300{fill:#55575B;}
+ .st301{fill:#EE424E;}
+ .st302{fill:#34424B;}
+</style>
+<g>
+ <g>
+ <path class="st55" d="M772.88,526c9.95,0,15.7,5.53,15.7,15.48c0,10.17-5.75,15.48-15.7,15.48c-9.95,0-15.48-5.31-15.48-15.48
+ C757.4,531.53,762.93,526,772.88,526z"/>
+ <path class="st55" d="M832.94,393.35c8.18,0,13.71,3.32,13.71,12.38c0,9.29-5.53,12.6-13.71,12.6c-8.4,0-14.15-3.32-14.15-12.6
+ C818.79,396.67,824.54,393.35,832.94,393.35z M821.22,438.67h22.99V554.3h-22.99V438.67z"/>
+ <path class="st55" d="M934.56,435.58c36.25,0,61.9,26.09,61.9,61.24c0,34.71-25.65,60.58-61.9,60.58
+ c-35.82,0-61.69-25.87-61.69-60.58C872.88,461.67,898.75,435.58,934.56,435.58z M934.56,536.18c23.66,0,39.79-17.03,39.79-39.36
+ c0-22.77-16.14-40.02-39.79-40.02c-23.44,0-39.36,17.25-39.36,40.02C895.21,519.15,911.13,536.18,934.56,536.18z"/>
+ </g>
+ <g>
+ <path class="st56" d="M724.15,245.36c-0.97-8.4-17.24-16.23-17.24-16.23c-6.81-4.71-8.03-16.23-7.16-20.6
+ c0.87-4.36,15.01-36.66,15.01-36.66c11.52-11.35,31.6-35.44,33.87-48.88c1.07-6.31-3.14-38.93,5.41-49.75
+ c5.63-7.12,22.35-15.36,25.84-18.16c3.49-2.79,6.28-11,4.71-14.84c-1.57-3.84-27.41-4.02-27.41-4.02s-2.16-10.79-17.62-11.8
+ c-6.05-0.2-10.18,1.3-10.18,1.3c1.78-0.89,4.69-2.18,6.78-3.1c-0.54-3.56-1.89-11.45-3.24-11.72c-1.43-0.29-4.73,5.28-6.27,7.21
+ l0.28,3.42l-1.11-2.85l-1.63-4.2l-0.02-0.06h0c-0.64-1.74-1.36-3.37-2.08-3.53c-1.57-0.34-38.06,50.81-42.6,57.44
+ c-4.54,6.63-9.25,17.81-33.87,25.49c-17.37,5.42-53.43,6.32-81.13,15.16l0,0c-14.25,1.43-53.29,33.79-83.15,32.62
+ c-20.05-0.78-35.61-6.28-39.57-15.25c0,0,4.19,41.32,25.6,43.64c0,0,13.5,2.33,26.53-9.31c0,0-3.72,7.36-8.38,9.5
+ c0,0,18.07-4.62,29.06-12.94c3.37-7.06,9.22-17.4,17.46-25.17c0.53-0.52,0.86-0.8,0.86-0.8c-0.29,0.26-0.57,0.53-0.86,0.8
+ c-2.65,2.6-10.68,11.48-11.86,24.73c-0.24,2.64-0.21,5.44,0.2,8.41c0,8.9,9.66,20.78,9.66,26.19c0,7.16-16.76,20.25-17.81,23.74
+ c-0.58,1.95-2.35,16.91,0.69,29.77h-59.09c-56.8,0-78.68,56.8-78.68,56.8l-24.4,68h-10.52c-8.25,0-14.94,6.69-14.94,14.94
+ c0,8.19,6.59,14.81,14.75,14.92l-0.01,0.02h0.2h5.92c8.25,0,14.94,6.69,14.94,14.94c0,8.25-6.69,14.94-14.94,14.94H197.01
+ c-8.25,0-14.94,6.69-14.94,14.94c0,8.25,6.69,14.94,14.94,14.94h154.15c8.25,0,14.94,6.69,14.94,14.94
+ c0,8.25-6.69,14.94-14.94,14.94h-32.39H146.48h-32.39c-8.25,0-14.94,6.69-14.94,14.94c0,8.25,6.69,14.94,14.94,14.94h183.08
+ l-13.71,38.21h113.6l35.67-99.4h1.72c79.83,0,109.91-85.2,109.91-85.2h-81.05l15.29-42.6l88.87,0c56.8,0,83.71-85.2,83.71-85.2
+ H539.99c-2.48-4.72-5.03-9.43-5.03-13.01c0-18.5,49.93-30.55,48.36-60.4c-4.08-16.85,2.51-25.92,2.51-25.92
+ c-4.2,9.35,0.01,19.79,2.11,24c0.03,0,0.06,0,0.1,0c13.97,0,41.9,15.36,59.18,15.36c11.43,0,20.41-2.37,25.19-3.97
+ c0.69-3.96,0.82-14.29-14.36-20.96c0,0,18.37,0.16,18.15,19.52c0,0-2.09,31.42,0.7,54.29c0.55,4.48,1.7,8.11,3.22,11.09h-0.52
+ c-13.7,55.08-55.31,85.2-55.31,85.2h60.7l-51.92,142.02l-0.04-0.01H510.67c-56.8,0-85.2,85.2-85.2,85.2H601.9
+ c56.8,0,111.69,5.33,144.18-85.2l40.77-113.6C810.25,293.33,776.46,249.98,724.15,245.36z"/>
+ <path class="st56" d="M56.25,489.25H18.47c-8.25,0-14.94,6.69-14.94,14.94c0,8.25,6.69,14.94,14.94,14.94h37.78
+ c8.25,0,14.94-6.69,14.94-14.94C71.19,495.94,64.5,489.25,56.25,489.25z"/>
+ <path class="st56" d="M171.38,399.61h120.5c8.25,0,14.94-6.69,14.94-14.94c0-8.25-6.69-14.94-14.94-14.94H102.14
+ c-8.25,0-14.94,6.69-14.94,14.94c0,8.25,6.69,14.94,14.94,14.94H171.38z"/>
+ <path class="st56" d="M180.84,339.85h162.85c8.25,0,14.94-6.69,14.94-14.94c0-8.25-6.69-14.94-14.94-14.94H180.84
+ c-8.25,0-14.94,6.69-14.94,14.94C165.9,333.17,172.59,339.85,180.84,339.85z"/>
+ </g>
+</g>
+</svg>
diff --git a/csit.infra.dash/app/cdash/static/img/logo.svg b/csit.infra.dash/app/cdash/static/img/logo.svg
new file mode 100644
index 0000000000..689757e3fd
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/img/logo.svg
@@ -0,0 +1,348 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+ viewBox="0 0 1000 568.31" style="enable-background:new 0 0 1000 568.31;" xml:space="preserve">
+<style type="text/css">
+ .st0{fill:#1DCAD3;}
+ .st1{fill:#36B0C9;}
+ .st2{fill:#231F20;}
+ .st3{fill:#FFFFFF;}
+ .st4{fill:#9164CC;}
+ .st5{clip-path:url(#SVGID_2_);fill:url(#SVGID_3_);}
+ .st6{fill:#201747;}
+ .st7{fill-rule:evenodd;clip-rule:evenodd;fill:#10CFC9;}
+ .st8{clip-path:url(#SVGID_5_);fill:#231F20;}
+ .st9{fill-rule:evenodd;clip-rule:evenodd;fill:#231F20;}
+ .st10{clip-path:url(#SVGID_7_);fill:#FFFFFF;}
+ .st11{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
+ .st12{fill:#8CCEAF;}
+ .st13{fill:#008476;}
+ .st14{fill:#25BCBD;}
+ .st15{fill:#004D70;}
+ .st16{fill:#20BBBB;}
+ .st17{fill:#024D70;}
+ .st18{fill-rule:evenodd;clip-rule:evenodd;fill:#F58B1F;}
+ .st19{fill-rule:evenodd;clip-rule:evenodd;fill:#18335B;}
+ .st20{clip-path:url(#SVGID_9_);}
+ .st21{clip-path:url(#SVGID_11_);}
+ .st22{fill:#18335B;}
+ .st23{fill:#F58B1F;}
+ .st24{clip-path:url(#SVGID_15_);}
+ .st25{clip-path:url(#SVGID_17_);}
+ .st26{clip-path:url(#SVGID_21_);}
+ .st27{clip-path:url(#SVGID_23_);}
+ .st28{clip-path:url(#SVGID_27_);}
+ .st29{clip-path:url(#SVGID_29_);}
+ .st30{clip-path:url(#SVGID_33_);}
+ .st31{clip-path:url(#SVGID_35_);}
+ .st32{clip-path:url(#SVGID_39_);}
+ .st33{clip-path:url(#SVGID_41_);}
+ .st34{fill:#416BA9;}
+ .st35{fill:#73C3D5;}
+ .st36{opacity:0.8;}
+ .st37{fill:#3A3A3A;}
+ .st38{fill:url(#SVGID_44_);}
+ .st39{fill:none;stroke:#000000;stroke-width:6.3384;}
+ .st40{fill:none;stroke:#000000;stroke-width:3.1692;}
+ .st41{fill:#48494B;}
+ .st42{fill:#C1986C;}
+ .st43{fill:url(#SVGID_63_);}
+ .st44{fill:url(#SVGID_64_);}
+ .st45{fill:url(#SVGID_65_);}
+ .st46{fill:url(#SVGID_66_);}
+ .st47{fill:url(#SVGID_67_);}
+ .st48{fill:#4D4E4E;}
+ .st49{fill:#27B373;}
+ .st50{fill:#5DC4CD;}
+ .st51{fill:#1E8756;}
+ .st52{fill:#3D1152;}
+ .st53{fill:#922C48;}
+ .st54{fill-rule:evenodd;clip-rule:evenodd;fill:#922C48;}
+ .st55{fill:#404041;}
+ .st56{fill:#EC1C24;}
+ .st57{fill:#373A36;}
+ .st58{fill:#808184;}
+ .st59{fill:#262261;}
+ .st60{fill:#6FCBDC;}
+ .st61{fill:#2F3436;}
+ .st62{fill:#5F97D0;}
+ .st63{fill:#132428;}
+ .st64{fill:#85C041;}
+ .st65{fill:#677784;}
+ .st66{fill:url(#SVGID_68_);}
+ .st67{opacity:0.2;clip-path:url(#SVGID_70_);}
+ .st68{fill:#FFFEFA;}
+ .st69{opacity:0.1;}
+ .st70{fill:url(#SVGID_71_);}
+ .st71{opacity:0.3;}
+ .st72{opacity:0.08;}
+ .st73{opacity:0.1;fill:url(#Wordmark_1_);}
+ .st74{fill:url(#SVGID_104_);}
+ .st75{opacity:0.6;fill:url(#SVGID_107_);}
+ .st76{opacity:0.4;}
+ .st77{fill:url(#SVGID_110_);}
+ .st78{opacity:0.6;fill:url(#SVGID_113_);}
+ .st79{fill:url(#SVGID_116_);}
+ .st80{opacity:0.6;fill:url(#SVGID_119_);}
+ .st81{fill:url(#SVGID_122_);}
+ .st82{opacity:0.6;fill:url(#SVGID_125_);}
+ .st83{fill:url(#SVGID_128_);}
+ .st84{opacity:0.6;fill:url(#SVGID_131_);}
+ .st85{fill:#221F1F;}
+ .st86{fill:none;}
+ .st87{fill:#00416B;}
+ .st88{opacity:0.8;fill:url(#XMLID_323_);}
+ .st89{fill:#4197CB;}
+ .st90{fill:#003E52;}
+ .st91{fill:#3F96B4;}
+ .st92{fill:#B9DBE5;}
+ .st93{opacity:0.3;fill:#231F20;}
+ .st94{opacity:0.3;fill:#FFFFFF;}
+ .st95{fill:#050013;}
+ .st96{fill:#E87200;}
+ .st97{fill:#FCB813;}
+ .st98{fill:#3D3935;}
+ .st99{fill:#FFB600;}
+ .st100{fill:#FCB814;}
+ .st101{fill:#F48120;}
+ .st102{fill:#EF4E25;}
+ .st103{fill:#ED3024;}
+ .st104{fill:#E0592A;}
+ .st105{fill:#00ADBB;}
+ .st106{fill:#00829B;}
+ .st107{fill:#93D500;}
+ .st108{fill:#4D5A31;}
+ .st109{fill:#6BA43A;}
+ .st110{fill:#424143;}
+ .st111{fill-rule:evenodd;clip-rule:evenodd;fill:#C7E6B4;}
+ .st112{fill-rule:evenodd;clip-rule:evenodd;fill:#5A9891;}
+ .st113{fill-rule:evenodd;clip-rule:evenodd;fill:#127870;}
+ .st114{fill-rule:evenodd;clip-rule:evenodd;fill:#5CCFD5;}
+ .st115{fill-rule:evenodd;clip-rule:evenodd;fill:#ACD5CD;}
+ .st116{fill-rule:evenodd;clip-rule:evenodd;fill:#B5ECC9;}
+ .st117{fill-rule:evenodd;clip-rule:evenodd;fill:#A1D683;}
+ .st118{fill-rule:evenodd;clip-rule:evenodd;fill:#DEF0D3;}
+ .st119{fill-rule:evenodd;clip-rule:evenodd;fill:#91B9B4;}
+ .st120{fill-rule:evenodd;clip-rule:evenodd;fill:#006860;}
+ .st121{fill-rule:evenodd;clip-rule:evenodd;fill:#00ADBB;}
+ .st122{fill-rule:evenodd;clip-rule:evenodd;fill:#B4E7E9;}
+ .st123{fill-rule:evenodd;clip-rule:evenodd;fill:#007565;}
+ .st124{fill-rule:evenodd;clip-rule:evenodd;fill:#00CE7C;}
+ .st125{fill-rule:evenodd;clip-rule:evenodd;fill:#5FD896;}
+ .st126{fill:#007DA5;}
+ .st127{fill:#313032;}
+ .st128{fill:#24272A;}
+ .st129{fill:#00AFAA;}
+ .st130{fill:#66C9BA;}
+ .st131{fill:#0069A7;}
+ .st132{fill:#002F87;}
+ .st133{fill:#8BC53F;}
+ .st134{fill:#1A1A1A;}
+ .st135{fill:#0095D6;}
+ .st136{fill:#003F5F;}
+ .st137{fill:#2D317C;}
+ .st138{fill:#41BFBF;}
+ .st139{fill:#293C97;}
+ .st140{fill:#52C2BD;}
+ .st141{fill:url(#SVGID_134_);}
+ .st142{fill:url(#SVGID_135_);}
+ .st143{fill:url(#SVGID_136_);}
+ .st144{fill:#0DBEEA;}
+ .st145{fill:#097EC2;}
+ .st146{fill:#133C63;}
+ .st147{fill:#3B91CF;}
+ .st148{fill:#C8DEE8;}
+ .st149{fill:#629BBA;}
+ .st150{fill:#F8BE19;}
+ .st151{fill:url(#SVGID_137_);}
+ .st152{fill:url(#SVGID_138_);}
+ .st153{fill:url(#SVGID_139_);}
+ .st154{fill:#00233B;}
+ .st155{fill:url(#SVGID_140_);}
+ .st156{fill:url(#SVGID_141_);}
+ .st157{fill:url(#SVGID_142_);}
+ .st158{fill:url(#SVGID_143_);}
+ .st159{fill:url(#SVGID_144_);}
+ .st160{fill:url(#SVGID_145_);}
+ .st161{fill:url(#SVGID_146_);}
+ .st162{fill:url(#SVGID_147_);}
+ .st163{fill:url(#SVGID_148_);}
+ .st164{fill:url(#SVGID_149_);}
+ .st165{fill:url(#SVGID_150_);}
+ .st166{fill:url(#SVGID_151_);}
+ .st167{fill:url(#SVGID_152_);}
+ .st168{fill:url(#SVGID_153_);}
+ .st169{fill:url(#SVGID_154_);}
+ .st170{fill:url(#SVGID_155_);}
+ .st171{fill:url(#SVGID_156_);}
+ .st172{fill:url(#SVGID_157_);}
+ .st173{fill:url(#SVGID_158_);}
+ .st174{fill:url(#SVGID_159_);}
+ .st175{fill:url(#SVGID_160_);}
+ .st176{fill:url(#SVGID_161_);}
+ .st177{fill:url(#SVGID_162_);}
+ .st178{fill:url(#SVGID_163_);}
+ .st179{fill:url(#SVGID_164_);}
+ .st180{fill:url(#SVGID_165_);}
+ .st181{fill:url(#SVGID_166_);}
+ .st182{fill:url(#SVGID_167_);}
+ .st183{fill:url(#SVGID_168_);}
+ .st184{fill:url(#SVGID_169_);}
+ .st185{fill:url(#SVGID_170_);}
+ .st186{fill:url(#SVGID_171_);}
+ .st187{fill:url(#SVGID_172_);}
+ .st188{fill:url(#SVGID_173_);}
+ .st189{fill:url(#SVGID_174_);}
+ .st190{fill:url(#SVGID_175_);}
+ .st191{fill:url(#SVGID_176_);}
+ .st192{fill:url(#SVGID_177_);}
+ .st193{fill:url(#SVGID_178_);}
+ .st194{fill:#C31230;}
+ .st195{fill:#807F82;}
+ .st196{fill-rule:evenodd;clip-rule:evenodd;fill:#C31230;}
+ .st197{fill-rule:evenodd;clip-rule:evenodd;fill:#807F82;}
+ .st198{fill:#2D2D2D;}
+ .st199{display:none;fill:#2D2D2D;}
+ .st200{fill:#D11F3C;}
+ .st201{fill:#E42C4C;stroke:#E42C4C;stroke-width:1.0503;stroke-miterlimit:10;}
+ .st202{display:none;fill:#231F20;}
+ .st203{display:none;fill:#FFFFFF;}
+ .st204{fill:#FF7F30;}
+ .st205{opacity:0.3;fill:#FF7F30;}
+ .st206{opacity:0.6;fill:#FF7F30;}
+ .st207{opacity:0.7;fill:#FF7F30;}
+ .st208{fill:#221C35;}
+ .st209{fill:#1B98D5;}
+ .st210{fill:#173963;}
+ .st211{fill:#009ADE;}
+ .st212{fill:#003764;}
+ .st213{fill:#2A7DE1;}
+ .st214{opacity:0.4;clip-path:url(#XMLID_324_);fill:#221F1F;}
+ .st215{fill:#002A3A;}
+ .st216{fill:#0033A1;}
+ .st217{fill:url(#SVGID_179_);}
+ .st218{fill:url(#SVGID_180_);}
+ .st219{fill:url(#SVGID_181_);}
+ .st220{fill:url(#SVGID_182_);}
+ .st221{fill:#007EC4;}
+ .st222{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_183_);}
+ .st223{fill-rule:evenodd;clip-rule:evenodd;fill:#E6E7E8;}
+ .st224{fill:#009345;}
+ .st225{fill:#BBBCB8;}
+ .st226{fill:#72C0EB;}
+ .st227{fill:#939598;}
+ .st228{fill-rule:evenodd;clip-rule:evenodd;fill:#2CB8EB;}
+ .st229{fill:#2CB8EB;}
+ .st230{fill:#81B83A;}
+ .st231{fill-rule:evenodd;clip-rule:evenodd;fill:#81B83A;}
+ .st232{enable-background:new ;}
+ .st233{fill:#FF6F3E;}
+ .st234{fill:#12143D;}
+ .st235{fill:url(#SVGID_184_);}
+ .st236{fill:url(#SVGID_185_);}
+ .st237{fill:url(#SVGID_186_);}
+ .st238{fill:url(#SVGID_187_);}
+ .st239{fill:url(#SVGID_188_);}
+ .st240{fill:url(#SVGID_189_);}
+ .st241{fill:url(#SVGID_190_);}
+ .st242{fill:url(#SVGID_191_);}
+ .st243{fill:url(#SVGID_192_);}
+ .st244{fill:#7C51A0;}
+ .st245{fill:#9F66A9;}
+ .st246{fill:#9F80B9;}
+ .st247{fill:url(#SVGID_193_);}
+ .st248{fill:url(#SVGID_194_);}
+ .st249{fill:url(#SVGID_195_);}
+ .st250{fill:url(#SVGID_196_);}
+ .st251{fill:#2D3136;}
+ .st252{fill:#76777A;}
+ .st253{fill:#A7A8A9;}
+ .st254{fill:#0082CA;}
+ .st255{fill:#FFB259;}
+ .st256{fill:#385CAD;}
+ .st257{fill:#7BA0C4;}
+ .st258{fill:#EBA900;}
+ .st259{fill:#929497;}
+ .st260{opacity:0.7;fill:#FFFFFF;}
+ .st261{fill:#016BAF;}
+ .st262{fill:#343432;}
+ .st263{fill:#6D6E70;}
+ .st264{fill:#F4B01B;}
+ .st265{fill:#293271;}
+ .st266{fill:#A1D33C;}
+ .st267{fill:#212322;}
+ .st268{fill:#0047BA;}
+ .st269{fill:#969CDE;}
+ .st270{fill:#047BC1;}
+ .st271{fill:url(#SVGID_197_);}
+ .st272{fill:url(#SVGID_198_);}
+ .st273{fill:url(#SVGID_199_);}
+ .st274{fill:url(#SVGID_200_);}
+ .st275{fill:url(#SVGID_201_);}
+ .st276{fill:url(#SVGID_202_);}
+ .st277{fill:url(#SVGID_203_);}
+ .st278{fill:#13517C;}
+ .st279{fill:#0077A6;}
+ .st280{fill:none;stroke:#231F20;stroke-width:5.9036;stroke-miterlimit:10;}
+ .st281{fill:#00A94F;}
+ .st282{fill:none;stroke:#231F20;stroke-width:3.2172;stroke-miterlimit:10;}
+ .st283{fill:#59595C;}
+ .st284{opacity:0.349;fill:#F9AE19;}
+ .st285{opacity:0.349;fill:#E99F22;}
+ .st286{opacity:0.349;fill:#E47D25;}
+ .st287{fill:#F9AE19;}
+ .st288{fill:#E99F22;}
+ .st289{fill:#F09B20;}
+ .st290{fill:#E47D25;}
+ .st291{fill:#E89223;}
+ .st292{opacity:0.651;fill:#F9AE19;}
+ .st293{fill:#E68825;}
+ .st294{opacity:0.651;fill:#E99F22;}
+ .st295{fill:#EB8D23;}
+ .st296{opacity:0.7725;fill:#EF9B21;}
+ .st297{opacity:0.651;fill:#E47D25;}
+ .st298{opacity:0.7725;fill:#EA9622;}
+ .st299{fill:url(#SVGID_204_);}
+ .st300{fill:#55575B;}
+ .st301{fill:#EE424E;}
+ .st302{fill:#34424B;}
+</style>
+<g>
+ <g>
+ <path class="st55" d="M772.88,526c9.95,0,15.7,5.53,15.7,15.48c0,10.17-5.75,15.48-15.7,15.48c-9.95,0-15.48-5.31-15.48-15.48
+ C757.4,531.53,762.93,526,772.88,526z"/>
+ <path class="st55" d="M832.94,393.35c8.18,0,13.71,3.32,13.71,12.38c0,9.29-5.53,12.6-13.71,12.6c-8.4,0-14.15-3.32-14.15-12.6
+ C818.79,396.67,824.54,393.35,832.94,393.35z M821.22,438.67h22.99V554.3h-22.99V438.67z"/>
+ <path class="st55" d="M934.56,435.58c36.25,0,61.9,26.09,61.9,61.24c0,34.71-25.65,60.58-61.9,60.58
+ c-35.82,0-61.69-25.87-61.69-60.58C872.88,461.67,898.75,435.58,934.56,435.58z M934.56,536.18c23.66,0,39.79-17.03,39.79-39.36
+ c0-22.77-16.14-40.02-39.79-40.02c-23.44,0-39.36,17.25-39.36,40.02C895.21,519.15,911.13,536.18,934.56,536.18z"/>
+ </g>
+ <g>
+ <path class="st56" d="M724.15,245.36c-0.97-8.4-17.24-16.23-17.24-16.23c-6.81-4.71-8.03-16.23-7.16-20.6
+ c0.87-4.36,15.01-36.66,15.01-36.66c11.52-11.35,31.6-35.44,33.87-48.88c1.07-6.31-3.14-38.93,5.41-49.75
+ c5.63-7.12,22.35-15.36,25.84-18.16c3.49-2.79,6.28-11,4.71-14.84c-1.57-3.84-27.41-4.02-27.41-4.02s-2.16-10.79-17.62-11.8
+ c-6.05-0.2-10.18,1.3-10.18,1.3c1.78-0.89,4.69-2.18,6.78-3.1c-0.54-3.56-1.89-11.45-3.24-11.72c-1.43-0.29-4.73,5.28-6.27,7.21
+ l0.28,3.42l-1.11-2.85l-1.63-4.2l-0.02-0.06h0c-0.64-1.74-1.36-3.37-2.08-3.53c-1.57-0.34-38.06,50.81-42.6,57.44
+ c-4.54,6.63-9.25,17.81-33.87,25.49c-17.37,5.42-53.43,6.32-81.13,15.16l0,0c-14.25,1.43-53.29,33.79-83.15,32.62
+ c-20.05-0.78-35.61-6.28-39.57-15.25c0,0,4.19,41.32,25.6,43.64c0,0,13.5,2.33,26.53-9.31c0,0-3.72,7.36-8.38,9.5
+ c0,0,18.07-4.62,29.06-12.94c3.37-7.06,9.22-17.4,17.46-25.17c0.53-0.52,0.86-0.8,0.86-0.8c-0.29,0.26-0.57,0.53-0.86,0.8
+ c-2.65,2.6-10.68,11.48-11.86,24.73c-0.24,2.64-0.21,5.44,0.2,8.41c0,8.9,9.66,20.78,9.66,26.19c0,7.16-16.76,20.25-17.81,23.74
+ c-0.58,1.95-2.35,16.91,0.69,29.77h-59.09c-56.8,0-78.68,56.8-78.68,56.8l-24.4,68h-10.52c-8.25,0-14.94,6.69-14.94,14.94
+ c0,8.19,6.59,14.81,14.75,14.92l-0.01,0.02h0.2h5.92c8.25,0,14.94,6.69,14.94,14.94c0,8.25-6.69,14.94-14.94,14.94H197.01
+ c-8.25,0-14.94,6.69-14.94,14.94c0,8.25,6.69,14.94,14.94,14.94h154.15c8.25,0,14.94,6.69,14.94,14.94
+ c0,8.25-6.69,14.94-14.94,14.94h-32.39H146.48h-32.39c-8.25,0-14.94,6.69-14.94,14.94c0,8.25,6.69,14.94,14.94,14.94h183.08
+ l-13.71,38.21h113.6l35.67-99.4h1.72c79.83,0,109.91-85.2,109.91-85.2h-81.05l15.29-42.6l88.87,0c56.8,0,83.71-85.2,83.71-85.2
+ H539.99c-2.48-4.72-5.03-9.43-5.03-13.01c0-18.5,49.93-30.55,48.36-60.4c-4.08-16.85,2.51-25.92,2.51-25.92
+ c-4.2,9.35,0.01,19.79,2.11,24c0.03,0,0.06,0,0.1,0c13.97,0,41.9,15.36,59.18,15.36c11.43,0,20.41-2.37,25.19-3.97
+ c0.69-3.96,0.82-14.29-14.36-20.96c0,0,18.37,0.16,18.15,19.52c0,0-2.09,31.42,0.7,54.29c0.55,4.48,1.7,8.11,3.22,11.09h-0.52
+ c-13.7,55.08-55.31,85.2-55.31,85.2h60.7l-51.92,142.02l-0.04-0.01H510.67c-56.8,0-85.2,85.2-85.2,85.2H601.9
+ c56.8,0,111.69,5.33,144.18-85.2l40.77-113.6C810.25,293.33,776.46,249.98,724.15,245.36z"/>
+ <path class="st56" d="M56.25,489.25H18.47c-8.25,0-14.94,6.69-14.94,14.94c0,8.25,6.69,14.94,14.94,14.94h37.78
+ c8.25,0,14.94-6.69,14.94-14.94C71.19,495.94,64.5,489.25,56.25,489.25z"/>
+ <path class="st56" d="M171.38,399.61h120.5c8.25,0,14.94-6.69,14.94-14.94c0-8.25-6.69-14.94-14.94-14.94H102.14
+ c-8.25,0-14.94,6.69-14.94,14.94c0,8.25,6.69,14.94,14.94,14.94H171.38z"/>
+ <path class="st56" d="M180.84,339.85h162.85c8.25,0,14.94-6.69,14.94-14.94c0-8.25-6.69-14.94-14.94-14.94H180.84
+ c-8.25,0-14.94,6.69-14.94,14.94C165.9,333.17,172.59,339.85,180.84,339.85z"/>
+ </g>
+</g>
+</svg>
diff --git a/csit.infra.dash/app/cdash/static/sass/_bootswatch.scss b/csit.infra.dash/app/cdash/static/sass/_bootswatch.scss
new file mode 100644
index 0000000000..900ccfb3c1
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/_bootswatch.scss
@@ -0,0 +1,178 @@
+// Lux 5.2.1
+// Bootswatch
+
+
+// Variables
+
+$web-font-path: "https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600&display=swap" !default;
+@if $web-font-path {
+ @import url($web-font-path);
+}
+
+:root {
+ color-scheme: light;
+}
+
+// Navbar
+
+.navbar {
+ font-size: $font-size-sm;
+ font-weight: 600;
+ text-transform: uppercase;
+
+ &-nav {
+ .nav-link {
+ padding-top: .715rem;
+ padding-bottom: .715rem;
+ }
+ }
+
+ &-brand {
+ margin-right: 2rem;
+ }
+}
+
+.bg-light {
+ border: 1px solid rgba(0, 0, 0, .1);
+
+ &.navbar-fixed-top {
+ border-width: 0 0 1px;
+ }
+
+ &.navbar-bottom-top {
+ border-width: 1px 0 0;
+ }
+}
+
+.nav-item {
+ margin-right: 2rem;
+}
+
+// Buttons
+
+.btn {
+ font-size: $font-size-sm;
+ text-transform: uppercase;
+
+ &-sm {
+ font-size: 10px;
+ }
+
+ &-warning {
+ &,
+ &:hover,
+ &:not([disabled]):not(.disabled):active,
+ &:focus {
+ color: $white;
+ }
+ }
+}
+
+.btn-outline-secondary {
+ color: $gray-600;
+ border-color: $gray-600;
+
+ &:not([disabled]):not(.disabled):hover,
+ &:not([disabled]):not(.disabled):focus,
+ &:not([disabled]):not(.disabled):active {
+ color: $white;
+ background-color: $gray-400;
+ border-color: $gray-400;
+ }
+
+ &:not([disabled]):not(.disabled):focus {
+ box-shadow: 0 0 0 .2rem rgba($gray-400, .5);
+ }
+}
+
+[class*="btn-outline-"] {
+ border-width: 2px;
+}
+
+.border-secondary {
+ border: 1px solid $gray-400 !important;
+}
+
+// Typography
+
+body {
+ font-weight: 200;
+ letter-spacing: 1px;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ text-transform: uppercase;
+ letter-spacing: 3px;
+}
+
+.text-secondary {
+ color: $body-color !important;
+}
+
+// Tables
+
+th {
+ font-size: $font-size-sm;
+ text-transform: uppercase;
+}
+
+.table {
+ th,
+ td {
+ padding: 1.5rem;
+ }
+
+ &-sm {
+ th,
+ td {
+ padding: .75rem;
+ }
+ }
+}
+
+// Navs
+
+.dropdown-menu {
+ font-size: $font-size-sm;
+ text-transform: none;
+}
+
+// Indicators
+
+.badge {
+ padding-top: .28rem;
+
+ &-pill {
+ border-radius: 10rem;
+ }
+
+ &.bg-secondary,
+ &.bg-light {
+ color: $dark;
+ }
+}
+
+// Containers
+
+.list-group-item,
+.card {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6,
+ .h1,
+ .h2,
+ .h3,
+ .h4,
+ .h5,
+ .h6 {
+ color: inherit;
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/_variables.scss b/csit.infra.dash/app/cdash/static/sass/_variables.scss
new file mode 100644
index 0000000000..6bfd6408b7
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/_variables.scss
@@ -0,0 +1,103 @@
+// Lux 5.2.1
+// Bootswatch
+
+$theme: "lux" !default;
+
+//
+// Color system
+//
+
+$white: #fff !default;
+$gray-100: #f8f9fa !default;
+$gray-200: #f7f7f9 !default;
+$gray-300: #eceeef !default;
+$gray-400: #ced4da !default;
+$gray-500: #adb5bd !default;
+$gray-600: #919aa1 !default;
+$gray-700: #55595c !default;
+$gray-800: #343a40 !default;
+$gray-900: #1a1a1a !default;
+$black: #000 !default;
+
+$blue: #007bff !default;
+$indigo: #6610f2 !default;
+$purple: #6f42c1 !default;
+$pink: #e83e8c !default;
+$red: #d9534f !default;
+$orange: #fd7e14 !default;
+$yellow: #f0ad4e !default;
+$green: #4bbf73 !default;
+$teal: #20c997 !default;
+$cyan: #1f9bcf !default;
+
+$primary: $gray-900 !default;
+$secondary: $white !default;
+$success: $green !default;
+$info: $cyan !default;
+$warning: $yellow !default;
+$danger: $red !default;
+$light: $white !default;
+$dark: $gray-800 !default;
+
+$min-contrast-ratio: 2.3 !default;
+
+// Options
+
+$enable-rounded: false !default;
+
+// Body
+
+$body-color: $gray-700 !default;
+
+// Fonts
+
+// stylelint-disable-next-line value-keyword-case
+$font-family-sans-serif: "Nunito Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol" !default;
+$h1-font-size: 2rem !default;
+$h2-font-size: 1.75rem !default;
+$h3-font-size: 1.5rem !default;
+$h4-font-size: 1.25rem !default;
+$h5-font-size: 1rem !default;
+$h6-font-size: .75rem !default;
+$headings-font-weight: 600 !default;
+$headings-color: $gray-900 !default;
+
+// Tables
+
+$table-border-color: rgba(0, 0, 0, .05) !default;
+
+// Buttons + Forms
+
+$input-btn-border-width: 0 !default;
+
+// Buttons
+
+$btn-line-height: 1.5rem !default;
+$input-btn-padding-y: .75rem !default;
+$input-btn-padding-x: 1.5rem !default;
+$input-btn-padding-y-sm: .5rem !default;
+$input-btn-padding-x-sm: 1rem !default;
+$input-btn-padding-y-lg: 2rem !default;
+$input-btn-padding-x-lg: 2rem !default;
+$btn-font-weight: 600 !default;
+
+// Forms
+
+$input-line-height: 1.5 !default;
+$input-bg: $gray-200 !default;
+$input-disabled-bg: $gray-300 !default;
+$input-group-addon-bg: $gray-300 !default;
+
+// Navbar
+
+$navbar-padding-y: 1.5rem !default;
+$navbar-dark-hover-color: $white !default;
+$navbar-light-color: rgba($black, .3) !default;
+$navbar-light-hover-color: $gray-900 !default;
+$navbar-light-active-color: $gray-900 !default;
+
+// Pagination
+
+$pagination-border-color: transparent !default;
+$pagination-hover-border-color: $pagination-border-color !default;
+$pagination-disabled-border-color: $pagination-border-color !default;
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_accordion.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_accordion.scss
new file mode 100644
index 0000000000..f09601bab6
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_accordion.scss
@@ -0,0 +1,149 @@
+//
+// Base styles
+//
+
+.accordion {
+ // scss-docs-start accordion-css-vars
+ --#{$prefix}accordion-color: #{$accordion-color};
+ --#{$prefix}accordion-bg: #{$accordion-bg};
+ --#{$prefix}accordion-transition: #{$accordion-transition};
+ --#{$prefix}accordion-border-color: #{$accordion-border-color};
+ --#{$prefix}accordion-border-width: #{$accordion-border-width};
+ --#{$prefix}accordion-border-radius: #{$accordion-border-radius};
+ --#{$prefix}accordion-inner-border-radius: #{$accordion-inner-border-radius};
+ --#{$prefix}accordion-btn-padding-x: #{$accordion-button-padding-x};
+ --#{$prefix}accordion-btn-padding-y: #{$accordion-button-padding-y};
+ --#{$prefix}accordion-btn-color: #{$accordion-button-color};
+ --#{$prefix}accordion-btn-bg: #{$accordion-button-bg};
+ --#{$prefix}accordion-btn-icon: #{escape-svg($accordion-button-icon)};
+ --#{$prefix}accordion-btn-icon-width: #{$accordion-icon-width};
+ --#{$prefix}accordion-btn-icon-transform: #{$accordion-icon-transform};
+ --#{$prefix}accordion-btn-icon-transition: #{$accordion-icon-transition};
+ --#{$prefix}accordion-btn-active-icon: #{escape-svg($accordion-button-active-icon)};
+ --#{$prefix}accordion-btn-focus-border-color: #{$accordion-button-focus-border-color};
+ --#{$prefix}accordion-btn-focus-box-shadow: #{$accordion-button-focus-box-shadow};
+ --#{$prefix}accordion-body-padding-x: #{$accordion-body-padding-x};
+ --#{$prefix}accordion-body-padding-y: #{$accordion-body-padding-y};
+ --#{$prefix}accordion-active-color: #{$accordion-button-active-color};
+ --#{$prefix}accordion-active-bg: #{$accordion-button-active-bg};
+ // scss-docs-end accordion-css-vars
+}
+
+.accordion-button {
+ position: relative;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: var(--#{$prefix}accordion-btn-padding-y) var(--#{$prefix}accordion-btn-padding-x);
+ @include font-size($font-size-base);
+ color: var(--#{$prefix}accordion-btn-color);
+ text-align: left; // Reset button style
+ background-color: var(--#{$prefix}accordion-btn-bg);
+ border: 0;
+ @include border-radius(0);
+ overflow-anchor: none;
+ @include transition(var(--#{$prefix}accordion-transition));
+
+ &:not(.collapsed) {
+ color: var(--#{$prefix}accordion-active-color);
+ background-color: var(--#{$prefix}accordion-active-bg);
+ box-shadow: inset 0 calc(-1 * var(--#{$prefix}accordion-border-width)) 0 var(--#{$prefix}accordion-border-color); // stylelint-disable-line function-disallowed-list
+
+ &::after {
+ background-image: var(--#{$prefix}accordion-btn-active-icon);
+ transform: var(--#{$prefix}accordion-btn-icon-transform);
+ }
+ }
+
+ // Accordion icon
+ &::after {
+ flex-shrink: 0;
+ width: var(--#{$prefix}accordion-btn-icon-width);
+ height: var(--#{$prefix}accordion-btn-icon-width);
+ margin-left: auto;
+ content: "";
+ background-image: var(--#{$prefix}accordion-btn-icon);
+ background-repeat: no-repeat;
+ background-size: var(--#{$prefix}accordion-btn-icon-width);
+ @include transition(var(--#{$prefix}accordion-btn-icon-transition));
+ }
+
+ &:hover {
+ z-index: 2;
+ }
+
+ &:focus {
+ z-index: 3;
+ border-color: var(--#{$prefix}accordion-btn-focus-border-color);
+ outline: 0;
+ box-shadow: var(--#{$prefix}accordion-btn-focus-box-shadow);
+ }
+}
+
+.accordion-header {
+ margin-bottom: 0;
+}
+
+.accordion-item {
+ color: var(--#{$prefix}accordion-color);
+ background-color: var(--#{$prefix}accordion-bg);
+ border: var(--#{$prefix}accordion-border-width) solid var(--#{$prefix}accordion-border-color);
+
+ &:first-of-type {
+ @include border-top-radius(var(--#{$prefix}accordion-border-radius));
+
+ .accordion-button {
+ @include border-top-radius(var(--#{$prefix}accordion-inner-border-radius));
+ }
+ }
+
+ &:not(:first-of-type) {
+ border-top: 0;
+ }
+
+ // Only set a border-radius on the last item if the accordion is collapsed
+ &:last-of-type {
+ @include border-bottom-radius(var(--#{$prefix}accordion-border-radius));
+
+ .accordion-button {
+ &.collapsed {
+ @include border-bottom-radius(var(--#{$prefix}accordion-inner-border-radius));
+ }
+ }
+
+ .accordion-collapse {
+ @include border-bottom-radius(var(--#{$prefix}accordion-border-radius));
+ }
+ }
+}
+
+.accordion-body {
+ padding: var(--#{$prefix}accordion-body-padding-y) var(--#{$prefix}accordion-body-padding-x);
+}
+
+
+// Flush accordion items
+//
+// Remove borders and border-radius to keep accordion items edge-to-edge.
+
+.accordion-flush {
+ .accordion-collapse {
+ border-width: 0;
+ }
+
+ .accordion-item {
+ border-right: 0;
+ border-left: 0;
+ @include border-radius(0);
+
+ &:first-child { border-top: 0; }
+ &:last-child { border-bottom: 0; }
+
+ .accordion-button {
+ &,
+ &.collapsed {
+ @include border-radius(0);
+ }
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_alert.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_alert.scss
new file mode 100644
index 0000000000..c8bc91b420
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_alert.scss
@@ -0,0 +1,71 @@
+//
+// Base styles
+//
+
+.alert {
+ // scss-docs-start alert-css-vars
+ --#{$prefix}alert-bg: transparent;
+ --#{$prefix}alert-padding-x: #{$alert-padding-x};
+ --#{$prefix}alert-padding-y: #{$alert-padding-y};
+ --#{$prefix}alert-margin-bottom: #{$alert-margin-bottom};
+ --#{$prefix}alert-color: inherit;
+ --#{$prefix}alert-border-color: transparent;
+ --#{$prefix}alert-border: #{$alert-border-width} solid var(--#{$prefix}alert-border-color);
+ --#{$prefix}alert-border-radius: #{$alert-border-radius};
+ // scss-docs-end alert-css-vars
+
+ position: relative;
+ padding: var(--#{$prefix}alert-padding-y) var(--#{$prefix}alert-padding-x);
+ margin-bottom: var(--#{$prefix}alert-margin-bottom);
+ color: var(--#{$prefix}alert-color);
+ background-color: var(--#{$prefix}alert-bg);
+ border: var(--#{$prefix}alert-border);
+ @include border-radius(var(--#{$prefix}alert-border-radius));
+}
+
+// Headings for larger alerts
+.alert-heading {
+ // Specified to prevent conflicts of changing $headings-color
+ color: inherit;
+}
+
+// Provide class for links that match alerts
+.alert-link {
+ font-weight: $alert-link-font-weight;
+}
+
+
+// Dismissible alerts
+//
+// Expand the right padding and account for the close button's positioning.
+
+.alert-dismissible {
+ padding-right: $alert-dismissible-padding-r;
+
+ // Adjust close link position
+ .btn-close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: $stretched-link-z-index + 1;
+ padding: $alert-padding-y * 1.25 $alert-padding-x;
+ }
+}
+
+
+// scss-docs-start alert-modifiers
+// Generate contextual modifier classes for colorizing the alert.
+
+@each $state, $value in $theme-colors {
+ $alert-background: shift-color($value, $alert-bg-scale);
+ $alert-border: shift-color($value, $alert-border-scale);
+ $alert-color: shift-color($value, $alert-color-scale);
+
+ @if (contrast-ratio($alert-background, $alert-color) < $min-contrast-ratio) {
+ $alert-color: mix($value, color-contrast($alert-background), abs($alert-color-scale));
+ }
+ .alert-#{$state} {
+ @include alert-variant($alert-background, $alert-border, $alert-color);
+ }
+}
+// scss-docs-end alert-modifiers
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_badge.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_badge.scss
new file mode 100644
index 0000000000..cc3d269556
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_badge.scss
@@ -0,0 +1,38 @@
+// Base class
+//
+// Requires one of the contextual, color modifier classes for `color` and
+// `background-color`.
+
+.badge {
+ // scss-docs-start badge-css-vars
+ --#{$prefix}badge-padding-x: #{$badge-padding-x};
+ --#{$prefix}badge-padding-y: #{$badge-padding-y};
+ @include rfs($badge-font-size, --#{$prefix}badge-font-size);
+ --#{$prefix}badge-font-weight: #{$badge-font-weight};
+ --#{$prefix}badge-color: #{$badge-color};
+ --#{$prefix}badge-border-radius: #{$badge-border-radius};
+ // scss-docs-end badge-css-vars
+
+ display: inline-block;
+ padding: var(--#{$prefix}badge-padding-y) var(--#{$prefix}badge-padding-x);
+ @include font-size(var(--#{$prefix}badge-font-size));
+ font-weight: var(--#{$prefix}badge-font-weight);
+ line-height: 1;
+ color: var(--#{$prefix}badge-color);
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: baseline;
+ @include border-radius(var(--#{$prefix}badge-border-radius));
+ @include gradient-bg();
+
+ // Empty badges collapse automatically
+ &:empty {
+ display: none;
+ }
+}
+
+// Quick fix for badges in buttons
+.btn .badge {
+ position: relative;
+ top: -1px;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_breadcrumb.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_breadcrumb.scss
new file mode 100644
index 0000000000..b8252ff215
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_breadcrumb.scss
@@ -0,0 +1,40 @@
+.breadcrumb {
+ // scss-docs-start breadcrumb-css-vars
+ --#{$prefix}breadcrumb-padding-x: #{$breadcrumb-padding-x};
+ --#{$prefix}breadcrumb-padding-y: #{$breadcrumb-padding-y};
+ --#{$prefix}breadcrumb-margin-bottom: #{$breadcrumb-margin-bottom};
+ @include rfs($breadcrumb-font-size, --#{$prefix}breadcrumb-font-size);
+ --#{$prefix}breadcrumb-bg: #{$breadcrumb-bg};
+ --#{$prefix}breadcrumb-border-radius: #{$breadcrumb-border-radius};
+ --#{$prefix}breadcrumb-divider-color: #{$breadcrumb-divider-color};
+ --#{$prefix}breadcrumb-item-padding-x: #{$breadcrumb-item-padding-x};
+ --#{$prefix}breadcrumb-item-active-color: #{$breadcrumb-active-color};
+ // scss-docs-end breadcrumb-css-vars
+
+ display: flex;
+ flex-wrap: wrap;
+ padding: var(--#{$prefix}breadcrumb-padding-y) var(--#{$prefix}breadcrumb-padding-x);
+ margin-bottom: var(--#{$prefix}breadcrumb-margin-bottom);
+ @include font-size(var(--#{$prefix}breadcrumb-font-size));
+ list-style: none;
+ background-color: var(--#{$prefix}breadcrumb-bg);
+ @include border-radius(var(--#{$prefix}breadcrumb-border-radius));
+}
+
+.breadcrumb-item {
+ // The separator between breadcrumbs (by default, a forward-slash: "/")
+ + .breadcrumb-item {
+ padding-left: var(--#{$prefix}breadcrumb-item-padding-x);
+
+ &::before {
+ float: left; // Suppress inline spacings and underlining of the separator
+ padding-right: var(--#{$prefix}breadcrumb-item-padding-x);
+ color: var(--#{$prefix}breadcrumb-divider-color);
+ content: var(--#{$prefix}breadcrumb-divider, escape-svg($breadcrumb-divider)) #{"/* rtl:"} var(--#{$prefix}breadcrumb-divider, escape-svg($breadcrumb-divider-flipped)) #{"*/"};
+ }
+ }
+
+ &.active {
+ color: var(--#{$prefix}breadcrumb-item-active-color);
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_button-group.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_button-group.scss
new file mode 100644
index 0000000000..79b100cbfb
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_button-group.scss
@@ -0,0 +1,142 @@
+// Make the div behave like a button
+.btn-group,
+.btn-group-vertical {
+ position: relative;
+ display: inline-flex;
+ vertical-align: middle; // match .btn alignment given font-size hack above
+
+ > .btn {
+ position: relative;
+ flex: 1 1 auto;
+ }
+
+ // Bring the hover, focused, and "active" buttons to the front to overlay
+ // the borders properly
+ > .btn-check:checked + .btn,
+ > .btn-check:focus + .btn,
+ > .btn:hover,
+ > .btn:focus,
+ > .btn:active,
+ > .btn.active {
+ z-index: 1;
+ }
+}
+
+// Optional: Group multiple button groups together for a toolbar
+.btn-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+
+ .input-group {
+ width: auto;
+ }
+}
+
+.btn-group {
+ @include border-radius($btn-border-radius);
+
+ // Prevent double borders when buttons are next to each other
+ > :not(.btn-check:first-child) + .btn,
+ > .btn-group:not(:first-child) {
+ margin-left: -$btn-border-width;
+ }
+
+ // Reset rounded corners
+ > .btn:not(:last-child):not(.dropdown-toggle),
+ > .btn.dropdown-toggle-split:first-child,
+ > .btn-group:not(:last-child) > .btn {
+ @include border-end-radius(0);
+ }
+
+ // The left radius should be 0 if the button is:
+ // - the "third or more" child
+ // - the second child and the previous element isn't `.btn-check` (making it the first child visually)
+ // - part of a btn-group which isn't the first child
+ > .btn:nth-child(n + 3),
+ > :not(.btn-check) + .btn,
+ > .btn-group:not(:first-child) > .btn {
+ @include border-start-radius(0);
+ }
+}
+
+// Sizing
+//
+// Remix the default button sizing classes into new ones for easier manipulation.
+
+.btn-group-sm > .btn { @extend .btn-sm; }
+.btn-group-lg > .btn { @extend .btn-lg; }
+
+
+//
+// Split button dropdowns
+//
+
+.dropdown-toggle-split {
+ padding-right: $btn-padding-x * .75;
+ padding-left: $btn-padding-x * .75;
+
+ &::after,
+ .dropup &::after,
+ .dropend &::after {
+ margin-left: 0;
+ }
+
+ .dropstart &::before {
+ margin-right: 0;
+ }
+}
+
+.btn-sm + .dropdown-toggle-split {
+ padding-right: $btn-padding-x-sm * .75;
+ padding-left: $btn-padding-x-sm * .75;
+}
+
+.btn-lg + .dropdown-toggle-split {
+ padding-right: $btn-padding-x-lg * .75;
+ padding-left: $btn-padding-x-lg * .75;
+}
+
+
+// The clickable button for toggling the menu
+// Set the same inset shadow as the :active state
+.btn-group.show .dropdown-toggle {
+ @include box-shadow($btn-active-box-shadow);
+
+ // Show no shadow for `.btn-link` since it has no other button styles.
+ &.btn-link {
+ @include box-shadow(none);
+ }
+}
+
+
+//
+// Vertical button groups
+//
+
+.btn-group-vertical {
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+
+ > .btn,
+ > .btn-group {
+ width: 100%;
+ }
+
+ > .btn:not(:first-child),
+ > .btn-group:not(:first-child) {
+ margin-top: -$btn-border-width;
+ }
+
+ // Reset rounded corners
+ > .btn:not(:last-child):not(.dropdown-toggle),
+ > .btn-group:not(:last-child) > .btn {
+ @include border-bottom-radius(0);
+ }
+
+ > .btn ~ .btn,
+ > .btn-group:not(:first-child) > .btn {
+ @include border-top-radius(0);
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_buttons.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_buttons.scss
new file mode 100644
index 0000000000..c2d0773516
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_buttons.scss
@@ -0,0 +1,201 @@
+//
+// Base styles
+//
+
+.btn {
+ // scss-docs-start btn-css-vars
+ --#{$prefix}btn-padding-x: #{$btn-padding-x};
+ --#{$prefix}btn-padding-y: #{$btn-padding-y};
+ --#{$prefix}btn-font-family: #{$btn-font-family};
+ @include rfs($btn-font-size, --#{$prefix}btn-font-size);
+ --#{$prefix}btn-font-weight: #{$btn-font-weight};
+ --#{$prefix}btn-line-height: #{$btn-line-height};
+ --#{$prefix}btn-color: #{$body-color};
+ --#{$prefix}btn-bg: transparent;
+ --#{$prefix}btn-border-width: #{$btn-border-width};
+ --#{$prefix}btn-border-color: transparent;
+ --#{$prefix}btn-border-radius: #{$btn-border-radius};
+ --#{$prefix}btn-hover-border-color: transparent;
+ --#{$prefix}btn-box-shadow: #{$btn-box-shadow};
+ --#{$prefix}btn-disabled-opacity: #{$btn-disabled-opacity};
+ --#{$prefix}btn-focus-box-shadow: 0 0 0 #{$btn-focus-width} rgba(var(--#{$prefix}btn-focus-shadow-rgb), .5);
+ // scss-docs-end btn-css-vars
+
+ display: inline-block;
+ padding: var(--#{$prefix}btn-padding-y) var(--#{$prefix}btn-padding-x);
+ font-family: var(--#{$prefix}btn-font-family);
+ @include font-size(var(--#{$prefix}btn-font-size));
+ font-weight: var(--#{$prefix}btn-font-weight);
+ line-height: var(--#{$prefix}btn-line-height);
+ color: var(--#{$prefix}btn-color);
+ text-align: center;
+ text-decoration: if($link-decoration == none, null, none);
+ white-space: $btn-white-space;
+ vertical-align: middle;
+ cursor: if($enable-button-pointers, pointer, null);
+ user-select: none;
+ border: var(--#{$prefix}btn-border-width) solid var(--#{$prefix}btn-border-color);
+ @include border-radius(var(--#{$prefix}btn-border-radius));
+ @include gradient-bg(var(--#{$prefix}btn-bg));
+ @include box-shadow(var(--#{$prefix}btn-box-shadow));
+ @include transition($btn-transition);
+
+ :not(.btn-check) + &:hover,
+ &:first-child:hover {
+ color: var(--#{$prefix}btn-hover-color);
+ text-decoration: if($link-hover-decoration == underline, none, null);
+ background-color: var(--#{$prefix}btn-hover-bg);
+ border-color: var(--#{$prefix}btn-hover-border-color);
+ }
+
+ &:focus-visible {
+ color: var(--#{$prefix}btn-hover-color);
+ @include gradient-bg(var(--#{$prefix}btn-hover-bg));
+ border-color: var(--#{$prefix}btn-hover-border-color);
+ outline: 0;
+ // Avoid using mixin so we can pass custom focus shadow properly
+ @if $enable-shadows {
+ box-shadow: var(--#{$prefix}btn-box-shadow), var(--#{$prefix}btn-focus-box-shadow);
+ } @else {
+ box-shadow: var(--#{$prefix}btn-focus-box-shadow);
+ }
+ }
+
+ .btn-check:focus-visible + & {
+ border-color: var(--#{$prefix}btn-hover-border-color);
+ outline: 0;
+ // Avoid using mixin so we can pass custom focus shadow properly
+ @if $enable-shadows {
+ box-shadow: var(--#{$prefix}btn-box-shadow), var(--#{$prefix}btn-focus-box-shadow);
+ } @else {
+ box-shadow: var(--#{$prefix}btn-focus-box-shadow);
+ }
+ }
+
+ .btn-check:checked + &,
+ :not(.btn-check) + &:active,
+ &:first-child:active,
+ &.active,
+ &.show {
+ color: var(--#{$prefix}btn-active-color);
+ background-color: var(--#{$prefix}btn-active-bg);
+ // Remove CSS gradients if they're enabled
+ background-image: if($enable-gradients, none, null);
+ border-color: var(--#{$prefix}btn-active-border-color);
+ @include box-shadow(var(--#{$prefix}btn-active-shadow));
+
+ &:focus-visible {
+ // Avoid using mixin so we can pass custom focus shadow properly
+ @if $enable-shadows {
+ box-shadow: var(--#{$prefix}btn-active-shadow), var(--#{$prefix}btn-focus-box-shadow);
+ } @else {
+ box-shadow: var(--#{$prefix}btn-focus-box-shadow);
+ }
+ }
+ }
+
+ &:disabled,
+ &.disabled,
+ fieldset:disabled & {
+ color: var(--#{$prefix}btn-disabled-color);
+ pointer-events: none;
+ background-color: var(--#{$prefix}btn-disabled-bg);
+ background-image: if($enable-gradients, none, null);
+ border-color: var(--#{$prefix}btn-disabled-border-color);
+ opacity: var(--#{$prefix}btn-disabled-opacity);
+ @include box-shadow(none);
+ }
+}
+
+
+//
+// Alternate buttons
+//
+
+// scss-docs-start btn-variant-loops
+@each $color, $value in $theme-colors {
+ .btn-#{$color} {
+ @if $color == "light" {
+ @include button-variant(
+ $value,
+ $value,
+ $hover-background: shade-color($value, $btn-hover-bg-shade-amount),
+ $hover-border: shade-color($value, $btn-hover-border-shade-amount),
+ $active-background: shade-color($value, $btn-active-bg-shade-amount),
+ $active-border: shade-color($value, $btn-active-border-shade-amount)
+ );
+ } @else if $color == "dark" {
+ @include button-variant(
+ $value,
+ $value,
+ $hover-background: tint-color($value, $btn-hover-bg-tint-amount),
+ $hover-border: tint-color($value, $btn-hover-border-tint-amount),
+ $active-background: tint-color($value, $btn-active-bg-tint-amount),
+ $active-border: tint-color($value, $btn-active-border-tint-amount)
+ );
+ } @else {
+ @include button-variant($value, $value);
+ }
+ }
+}
+
+@each $color, $value in $theme-colors {
+ .btn-outline-#{$color} {
+ @include button-outline-variant($value);
+ }
+}
+// scss-docs-end btn-variant-loops
+
+
+//
+// Link buttons
+//
+
+// Make a button look and behave like a link
+.btn-link {
+ --#{$prefix}btn-font-weight: #{$font-weight-normal};
+ --#{$prefix}btn-color: #{$btn-link-color};
+ --#{$prefix}btn-bg: transparent;
+ --#{$prefix}btn-border-color: transparent;
+ --#{$prefix}btn-hover-color: #{$btn-link-hover-color};
+ --#{$prefix}btn-hover-border-color: transparent;
+ --#{$prefix}btn-active-color: #{$btn-link-hover-color};
+ --#{$prefix}btn-active-border-color: transparent;
+ --#{$prefix}btn-disabled-color: #{$btn-link-disabled-color};
+ --#{$prefix}btn-disabled-border-color: transparent;
+ --#{$prefix}btn-box-shadow: none;
+ --#{$prefix}btn-focus-shadow-rgb: #{to-rgb(mix(color-contrast($primary), $primary, 15%))};
+
+ text-decoration: $link-decoration;
+ @if $enable-gradients {
+ background-image: none;
+ }
+
+ &:hover,
+ &:focus-visible {
+ text-decoration: $link-hover-decoration;
+ }
+
+ &:focus-visible {
+ color: var(--#{$prefix}btn-color);
+ }
+
+ &:hover {
+ color: var(--#{$prefix}btn-hover-color);
+ }
+
+ // No need for an active state here
+}
+
+
+//
+// Button Sizes
+//
+
+.btn-lg {
+ @include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-border-radius-lg);
+}
+
+.btn-sm {
+ @include button-size($btn-padding-y-sm, $btn-padding-x-sm, $btn-font-size-sm, $btn-border-radius-sm);
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_card.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_card.scss
new file mode 100644
index 0000000000..ce8c02f1f2
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_card.scss
@@ -0,0 +1,234 @@
+//
+// Base styles
+//
+
+.card {
+ // scss-docs-start card-css-vars
+ --#{$prefix}card-spacer-y: #{$card-spacer-y};
+ --#{$prefix}card-spacer-x: #{$card-spacer-x};
+ --#{$prefix}card-title-spacer-y: #{$card-title-spacer-y};
+ --#{$prefix}card-border-width: #{$card-border-width};
+ --#{$prefix}card-border-color: #{$card-border-color};
+ --#{$prefix}card-border-radius: #{$card-border-radius};
+ --#{$prefix}card-box-shadow: #{$card-box-shadow};
+ --#{$prefix}card-inner-border-radius: #{$card-inner-border-radius};
+ --#{$prefix}card-cap-padding-y: #{$card-cap-padding-y};
+ --#{$prefix}card-cap-padding-x: #{$card-cap-padding-x};
+ --#{$prefix}card-cap-bg: #{$card-cap-bg};
+ --#{$prefix}card-cap-color: #{$card-cap-color};
+ --#{$prefix}card-height: #{$card-height};
+ --#{$prefix}card-color: #{$card-color};
+ --#{$prefix}card-bg: #{$card-bg};
+ --#{$prefix}card-img-overlay-padding: #{$card-img-overlay-padding};
+ --#{$prefix}card-group-margin: #{$card-group-margin};
+ // scss-docs-end card-css-vars
+
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ min-width: 0; // See https://github.com/twbs/bootstrap/pull/22740#issuecomment-305868106
+ height: var(--#{$prefix}card-height);
+ word-wrap: break-word;
+ background-color: var(--#{$prefix}card-bg);
+ background-clip: border-box;
+ border: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
+ @include border-radius(var(--#{$prefix}card-border-radius));
+ @include box-shadow(var(--#{$prefix}card-box-shadow));
+
+ > hr {
+ margin-right: 0;
+ margin-left: 0;
+ }
+
+ > .list-group {
+ border-top: inherit;
+ border-bottom: inherit;
+
+ &:first-child {
+ border-top-width: 0;
+ @include border-top-radius(var(--#{$prefix}card-inner-border-radius));
+ }
+
+ &:last-child {
+ border-bottom-width: 0;
+ @include border-bottom-radius(var(--#{$prefix}card-inner-border-radius));
+ }
+ }
+
+ // Due to specificity of the above selector (`.card > .list-group`), we must
+ // use a child selector here to prevent double borders.
+ > .card-header + .list-group,
+ > .list-group + .card-footer {
+ border-top: 0;
+ }
+}
+
+.card-body {
+ // Enable `flex-grow: 1` for decks and groups so that card blocks take up
+ // as much space as possible, ensuring footers are aligned to the bottom.
+ flex: 1 1 auto;
+ padding: var(--#{$prefix}card-spacer-y) var(--#{$prefix}card-spacer-x);
+ color: var(--#{$prefix}card-color);
+}
+
+.card-title {
+ margin-bottom: var(--#{$prefix}card-title-spacer-y);
+}
+
+.card-subtitle {
+ margin-top: calc(-.5 * var(--#{$prefix}card-title-spacer-y)); // stylelint-disable-line function-disallowed-list
+ margin-bottom: 0;
+}
+
+.card-text:last-child {
+ margin-bottom: 0;
+}
+
+.card-link {
+ &:hover {
+ text-decoration: if($link-hover-decoration == underline, none, null);
+ }
+
+ + .card-link {
+ margin-left: var(--#{$prefix}card-spacer-x);
+ }
+}
+
+//
+// Optional textual caps
+//
+
+.card-header {
+ padding: var(--#{$prefix}card-cap-padding-y) var(--#{$prefix}card-cap-padding-x);
+ margin-bottom: 0; // Removes the default margin-bottom of <hN>
+ color: var(--#{$prefix}card-cap-color);
+ background-color: var(--#{$prefix}card-cap-bg);
+ border-bottom: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
+
+ &:first-child {
+ @include border-radius(var(--#{$prefix}card-inner-border-radius) var(--#{$prefix}card-inner-border-radius) 0 0);
+ }
+}
+
+.card-footer {
+ padding: var(--#{$prefix}card-cap-padding-y) var(--#{$prefix}card-cap-padding-x);
+ color: var(--#{$prefix}card-cap-color);
+ background-color: var(--#{$prefix}card-cap-bg);
+ border-top: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
+
+ &:last-child {
+ @include border-radius(0 0 var(--#{$prefix}card-inner-border-radius) var(--#{$prefix}card-inner-border-radius));
+ }
+}
+
+
+//
+// Header navs
+//
+
+.card-header-tabs {
+ margin-right: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
+ margin-bottom: calc(-1 * var(--#{$prefix}card-cap-padding-y)); // stylelint-disable-line function-disallowed-list
+ margin-left: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
+ border-bottom: 0;
+
+ .nav-link.active {
+ background-color: var(--#{$prefix}card-bg);
+ border-bottom-color: var(--#{$prefix}card-bg);
+ }
+}
+
+.card-header-pills {
+ margin-right: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
+ margin-left: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
+}
+
+// Card image
+.card-img-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ padding: var(--#{$prefix}card-img-overlay-padding);
+ @include border-radius(var(--#{$prefix}card-inner-border-radius));
+}
+
+.card-img,
+.card-img-top,
+.card-img-bottom {
+ width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
+}
+
+.card-img,
+.card-img-top {
+ @include border-top-radius(var(--#{$prefix}card-inner-border-radius));
+}
+
+.card-img,
+.card-img-bottom {
+ @include border-bottom-radius(var(--#{$prefix}card-inner-border-radius));
+}
+
+
+//
+// Card groups
+//
+
+.card-group {
+ // The child selector allows nested `.card` within `.card-group`
+ // to display properly.
+ > .card {
+ margin-bottom: var(--#{$prefix}card-group-margin);
+ }
+
+ @include media-breakpoint-up(sm) {
+ display: flex;
+ flex-flow: row wrap;
+ // The child selector allows nested `.card` within `.card-group`
+ // to display properly.
+ > .card {
+ // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4
+ flex: 1 0 0%;
+ margin-bottom: 0;
+
+ + .card {
+ margin-left: 0;
+ border-left: 0;
+ }
+
+ // Handle rounded corners
+ @if $enable-rounded {
+ &:not(:last-child) {
+ @include border-end-radius(0);
+
+ .card-img-top,
+ .card-header {
+ // stylelint-disable-next-line property-disallowed-list
+ border-top-right-radius: 0;
+ }
+ .card-img-bottom,
+ .card-footer {
+ // stylelint-disable-next-line property-disallowed-list
+ border-bottom-right-radius: 0;
+ }
+ }
+
+ &:not(:first-child) {
+ @include border-start-radius(0);
+
+ .card-img-top,
+ .card-header {
+ // stylelint-disable-next-line property-disallowed-list
+ border-top-left-radius: 0;
+ }
+ .card-img-bottom,
+ .card-footer {
+ // stylelint-disable-next-line property-disallowed-list
+ border-bottom-left-radius: 0;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_carousel.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_carousel.scss
new file mode 100644
index 0000000000..3d8fb15a06
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_carousel.scss
@@ -0,0 +1,229 @@
+// Notes on the classes:
+//
+// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)
+// even when their scroll action started on a carousel, but for compatibility (with Firefox)
+// we're preventing all actions instead
+// 2. The .carousel-item-start and .carousel-item-end is used to indicate where
+// the active slide is heading.
+// 3. .active.carousel-item is the current slide.
+// 4. .active.carousel-item-start and .active.carousel-item-end is the current
+// slide in its in-transition state. Only one of these occurs at a time.
+// 5. .carousel-item-next.carousel-item-start and .carousel-item-prev.carousel-item-end
+// is the upcoming slide in transition.
+
+.carousel {
+ position: relative;
+}
+
+.carousel.pointer-event {
+ touch-action: pan-y;
+}
+
+.carousel-inner {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+ @include clearfix();
+}
+
+.carousel-item {
+ position: relative;
+ display: none;
+ float: left;
+ width: 100%;
+ margin-right: -100%;
+ backface-visibility: hidden;
+ @include transition($carousel-transition);
+}
+
+.carousel-item.active,
+.carousel-item-next,
+.carousel-item-prev {
+ display: block;
+}
+
+/* rtl:begin:ignore */
+.carousel-item-next:not(.carousel-item-start),
+.active.carousel-item-end {
+ transform: translateX(100%);
+}
+
+.carousel-item-prev:not(.carousel-item-end),
+.active.carousel-item-start {
+ transform: translateX(-100%);
+}
+
+/* rtl:end:ignore */
+
+
+//
+// Alternate transitions
+//
+
+.carousel-fade {
+ .carousel-item {
+ opacity: 0;
+ transition-property: opacity;
+ transform: none;
+ }
+
+ .carousel-item.active,
+ .carousel-item-next.carousel-item-start,
+ .carousel-item-prev.carousel-item-end {
+ z-index: 1;
+ opacity: 1;
+ }
+
+ .active.carousel-item-start,
+ .active.carousel-item-end {
+ z-index: 0;
+ opacity: 0;
+ @include transition(opacity 0s $carousel-transition-duration);
+ }
+}
+
+
+//
+// Left/right controls for nav
+//
+
+.carousel-control-prev,
+.carousel-control-next {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ z-index: 1;
+ // Use flex for alignment (1-3)
+ display: flex; // 1. allow flex styles
+ align-items: center; // 2. vertically center contents
+ justify-content: center; // 3. horizontally center contents
+ width: $carousel-control-width;
+ padding: 0;
+ color: $carousel-control-color;
+ text-align: center;
+ background: none;
+ border: 0;
+ opacity: $carousel-control-opacity;
+ @include transition($carousel-control-transition);
+
+ // Hover/focus state
+ &:hover,
+ &:focus {
+ color: $carousel-control-color;
+ text-decoration: none;
+ outline: 0;
+ opacity: $carousel-control-hover-opacity;
+ }
+}
+.carousel-control-prev {
+ left: 0;
+ background-image: if($enable-gradients, linear-gradient(90deg, rgba($black, .25), rgba($black, .001)), null);
+}
+.carousel-control-next {
+ right: 0;
+ background-image: if($enable-gradients, linear-gradient(270deg, rgba($black, .25), rgba($black, .001)), null);
+}
+
+// Icons for within
+.carousel-control-prev-icon,
+.carousel-control-next-icon {
+ display: inline-block;
+ width: $carousel-control-icon-width;
+ height: $carousel-control-icon-width;
+ background-repeat: no-repeat;
+ background-position: 50%;
+ background-size: 100% 100%;
+}
+
+/* rtl:options: {
+ "autoRename": true,
+ "stringMap":[ {
+ "name" : "prev-next",
+ "search" : "prev",
+ "replace" : "next"
+ } ]
+} */
+.carousel-control-prev-icon {
+ background-image: escape-svg($carousel-control-prev-icon-bg);
+}
+.carousel-control-next-icon {
+ background-image: escape-svg($carousel-control-next-icon-bg);
+}
+
+// Optional indicator pips/controls
+//
+// Add a container (such as a list) with the following class and add an item (ideally a focusable control,
+// like a button) with data-bs-target for each slide your carousel holds.
+
+.carousel-indicators {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 2;
+ display: flex;
+ justify-content: center;
+ padding: 0;
+ // Use the .carousel-control's width as margin so we don't overlay those
+ margin-right: $carousel-control-width;
+ margin-bottom: 1rem;
+ margin-left: $carousel-control-width;
+ list-style: none;
+
+ [data-bs-target] {
+ box-sizing: content-box;
+ flex: 0 1 auto;
+ width: $carousel-indicator-width;
+ height: $carousel-indicator-height;
+ padding: 0;
+ margin-right: $carousel-indicator-spacer;
+ margin-left: $carousel-indicator-spacer;
+ text-indent: -999px;
+ cursor: pointer;
+ background-color: $carousel-indicator-active-bg;
+ background-clip: padding-box;
+ border: 0;
+ // Use transparent borders to increase the hit area by 10px on top and bottom.
+ border-top: $carousel-indicator-hit-area-height solid transparent;
+ border-bottom: $carousel-indicator-hit-area-height solid transparent;
+ opacity: $carousel-indicator-opacity;
+ @include transition($carousel-indicator-transition);
+ }
+
+ .active {
+ opacity: $carousel-indicator-active-opacity;
+ }
+}
+
+
+// Optional captions
+//
+//
+
+.carousel-caption {
+ position: absolute;
+ right: (100% - $carousel-caption-width) * .5;
+ bottom: $carousel-caption-spacer;
+ left: (100% - $carousel-caption-width) * .5;
+ padding-top: $carousel-caption-padding-y;
+ padding-bottom: $carousel-caption-padding-y;
+ color: $carousel-caption-color;
+ text-align: center;
+}
+
+// Dark mode carousel
+
+.carousel-dark {
+ .carousel-control-prev-icon,
+ .carousel-control-next-icon {
+ filter: $carousel-dark-control-icon-filter;
+ }
+
+ .carousel-indicators [data-bs-target] {
+ background-color: $carousel-dark-indicator-active-bg;
+ }
+
+ .carousel-caption {
+ color: $carousel-dark-caption-color;
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_close.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_close.scss
new file mode 100644
index 0000000000..a0813de8d3
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_close.scss
@@ -0,0 +1,40 @@
+// Transparent background and border properties included for button version.
+// iOS requires the button element instead of an anchor tag.
+// If you want the anchor version, it requires `href="#"`.
+// See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
+
+.btn-close {
+ box-sizing: content-box;
+ width: $btn-close-width;
+ height: $btn-close-height;
+ padding: $btn-close-padding-y $btn-close-padding-x;
+ color: $btn-close-color;
+ background: transparent escape-svg($btn-close-bg) center / $btn-close-width auto no-repeat; // include transparent for button elements
+ border: 0; // for button elements
+ @include border-radius();
+ opacity: $btn-close-opacity;
+
+ // Override <a>'s hover style
+ &:hover {
+ color: $btn-close-color;
+ text-decoration: none;
+ opacity: $btn-close-hover-opacity;
+ }
+
+ &:focus {
+ outline: 0;
+ box-shadow: $btn-close-focus-shadow;
+ opacity: $btn-close-focus-opacity;
+ }
+
+ &:disabled,
+ &.disabled {
+ pointer-events: none;
+ user-select: none;
+ opacity: $btn-close-disabled-opacity;
+ }
+}
+
+.btn-close-white {
+ filter: $btn-close-white-filter;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_containers.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_containers.scss
new file mode 100644
index 0000000000..83b31381bf
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_containers.scss
@@ -0,0 +1,41 @@
+// Container widths
+//
+// Set the container width, and override it for fixed navbars in media queries.
+
+@if $enable-container-classes {
+ // Single container class with breakpoint max-widths
+ .container,
+ // 100% wide container at all breakpoints
+ .container-fluid {
+ @include make-container();
+ }
+
+ // Responsive containers that are 100% wide until a breakpoint
+ @each $breakpoint, $container-max-width in $container-max-widths {
+ .container-#{$breakpoint} {
+ @extend .container-fluid;
+ }
+
+ @include media-breakpoint-up($breakpoint, $grid-breakpoints) {
+ %responsive-container-#{$breakpoint} {
+ max-width: $container-max-width;
+ }
+
+ // Extend each breakpoint which is smaller or equal to the current breakpoint
+ $extend-breakpoint: true;
+
+ @each $name, $width in $grid-breakpoints {
+ @if ($extend-breakpoint) {
+ .container#{breakpoint-infix($name, $grid-breakpoints)} {
+ @extend %responsive-container-#{$breakpoint};
+ }
+
+ // Once the current breakpoint is reached, stop extending
+ @if ($breakpoint == $name) {
+ $extend-breakpoint: false;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_dropdown.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_dropdown.scss
new file mode 100644
index 0000000000..8899d25a0d
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_dropdown.scss
@@ -0,0 +1,249 @@
+// The dropdown wrapper (`<div>`)
+.dropup,
+.dropend,
+.dropdown,
+.dropstart,
+.dropup-center,
+.dropdown-center {
+ position: relative;
+}
+
+.dropdown-toggle {
+ white-space: nowrap;
+
+ // Generate the caret automatically
+ @include caret();
+}
+
+// The dropdown menu
+.dropdown-menu {
+ // scss-docs-start dropdown-css-vars
+ --#{$prefix}dropdown-zindex: #{$zindex-dropdown};
+ --#{$prefix}dropdown-min-width: #{$dropdown-min-width};
+ --#{$prefix}dropdown-padding-x: #{$dropdown-padding-x};
+ --#{$prefix}dropdown-padding-y: #{$dropdown-padding-y};
+ --#{$prefix}dropdown-spacer: #{$dropdown-spacer};
+ @include rfs($dropdown-font-size, --#{$prefix}dropdown-font-size);
+ --#{$prefix}dropdown-color: #{$dropdown-color};
+ --#{$prefix}dropdown-bg: #{$dropdown-bg};
+ --#{$prefix}dropdown-border-color: #{$dropdown-border-color};
+ --#{$prefix}dropdown-border-radius: #{$dropdown-border-radius};
+ --#{$prefix}dropdown-border-width: #{$dropdown-border-width};
+ --#{$prefix}dropdown-inner-border-radius: #{$dropdown-inner-border-radius};
+ --#{$prefix}dropdown-divider-bg: #{$dropdown-divider-bg};
+ --#{$prefix}dropdown-divider-margin-y: #{$dropdown-divider-margin-y};
+ --#{$prefix}dropdown-box-shadow: #{$dropdown-box-shadow};
+ --#{$prefix}dropdown-link-color: #{$dropdown-link-color};
+ --#{$prefix}dropdown-link-hover-color: #{$dropdown-link-hover-color};
+ --#{$prefix}dropdown-link-hover-bg: #{$dropdown-link-hover-bg};
+ --#{$prefix}dropdown-link-active-color: #{$dropdown-link-active-color};
+ --#{$prefix}dropdown-link-active-bg: #{$dropdown-link-active-bg};
+ --#{$prefix}dropdown-link-disabled-color: #{$dropdown-link-disabled-color};
+ --#{$prefix}dropdown-item-padding-x: #{$dropdown-item-padding-x};
+ --#{$prefix}dropdown-item-padding-y: #{$dropdown-item-padding-y};
+ --#{$prefix}dropdown-header-color: #{$dropdown-header-color};
+ --#{$prefix}dropdown-header-padding-x: #{$dropdown-header-padding-x};
+ --#{$prefix}dropdown-header-padding-y: #{$dropdown-header-padding-y};
+ // scss-docs-end dropdown-css-vars
+
+ position: absolute;
+ z-index: var(--#{$prefix}dropdown-zindex);
+ display: none; // none by default, but block on "open" of the menu
+ min-width: var(--#{$prefix}dropdown-min-width);
+ padding: var(--#{$prefix}dropdown-padding-y) var(--#{$prefix}dropdown-padding-x);
+ margin: 0; // Override default margin of ul
+ @include font-size(var(--#{$prefix}dropdown-font-size));
+ color: var(--#{$prefix}dropdown-color);
+ text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)
+ list-style: none;
+ background-color: var(--#{$prefix}dropdown-bg);
+ background-clip: padding-box;
+ border: var(--#{$prefix}dropdown-border-width) solid var(--#{$prefix}dropdown-border-color);
+ @include border-radius(var(--#{$prefix}dropdown-border-radius));
+ @include box-shadow(var(--#{$prefix}dropdown-box-shadow));
+
+ &[data-bs-popper] {
+ top: 100%;
+ left: 0;
+ margin-top: var(--#{$prefix}dropdown-spacer);
+ }
+
+ @if $dropdown-padding-y == 0 {
+ > .dropdown-item:first-child,
+ > li:first-child .dropdown-item {
+ @include border-top-radius(var(--#{$prefix}dropdown-inner-border-radius));
+ }
+ > .dropdown-item:last-child,
+ > li:last-child .dropdown-item {
+ @include border-bottom-radius(var(--#{$prefix}dropdown-inner-border-radius));
+ }
+
+ }
+}
+
+// scss-docs-start responsive-breakpoints
+// We deliberately hardcode the `bs-` prefix because we check
+// this custom property in JS to determine Popper's positioning
+
+@each $breakpoint in map-keys($grid-breakpoints) {
+ @include media-breakpoint-up($breakpoint) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+
+ .dropdown-menu#{$infix}-start {
+ --bs-position: start;
+
+ &[data-bs-popper] {
+ right: auto;
+ left: 0;
+ }
+ }
+
+ .dropdown-menu#{$infix}-end {
+ --bs-position: end;
+
+ &[data-bs-popper] {
+ right: 0;
+ left: auto;
+ }
+ }
+ }
+}
+// scss-docs-end responsive-breakpoints
+
+// Allow for dropdowns to go bottom up (aka, dropup-menu)
+// Just add .dropup after the standard .dropdown class and you're set.
+.dropup {
+ .dropdown-menu[data-bs-popper] {
+ top: auto;
+ bottom: 100%;
+ margin-top: 0;
+ margin-bottom: var(--#{$prefix}dropdown-spacer);
+ }
+
+ .dropdown-toggle {
+ @include caret(up);
+ }
+}
+
+.dropend {
+ .dropdown-menu[data-bs-popper] {
+ top: 0;
+ right: auto;
+ left: 100%;
+ margin-top: 0;
+ margin-left: var(--#{$prefix}dropdown-spacer);
+ }
+
+ .dropdown-toggle {
+ @include caret(end);
+ &::after {
+ vertical-align: 0;
+ }
+ }
+}
+
+.dropstart {
+ .dropdown-menu[data-bs-popper] {
+ top: 0;
+ right: 100%;
+ left: auto;
+ margin-top: 0;
+ margin-right: var(--#{$prefix}dropdown-spacer);
+ }
+
+ .dropdown-toggle {
+ @include caret(start);
+ &::before {
+ vertical-align: 0;
+ }
+ }
+}
+
+
+// Dividers (basically an `<hr>`) within the dropdown
+.dropdown-divider {
+ height: 0;
+ margin: var(--#{$prefix}dropdown-divider-margin-y) 0;
+ overflow: hidden;
+ border-top: 1px solid var(--#{$prefix}dropdown-divider-bg);
+ opacity: 1; // Revisit in v6 to de-dupe styles that conflict with <hr> element
+}
+
+// Links, buttons, and more within the dropdown menu
+//
+// `<button>`-specific styles are denoted with `// For <button>s`
+.dropdown-item {
+ display: block;
+ width: 100%; // For `<button>`s
+ padding: var(--#{$prefix}dropdown-item-padding-y) var(--#{$prefix}dropdown-item-padding-x);
+ clear: both;
+ font-weight: $font-weight-normal;
+ color: var(--#{$prefix}dropdown-link-color);
+ text-align: inherit; // For `<button>`s
+ text-decoration: if($link-decoration == none, null, none);
+ white-space: nowrap; // prevent links from randomly breaking onto new lines
+ background-color: transparent; // For `<button>`s
+ border: 0; // For `<button>`s
+
+ &:hover,
+ &:focus {
+ color: var(--#{$prefix}dropdown-link-hover-color);
+ text-decoration: if($link-hover-decoration == underline, none, null);
+ @include gradient-bg(var(--#{$prefix}dropdown-link-hover-bg));
+ }
+
+ &.active,
+ &:active {
+ color: var(--#{$prefix}dropdown-link-active-color);
+ text-decoration: none;
+ @include gradient-bg(var(--#{$prefix}dropdown-link-active-bg));
+ }
+
+ &.disabled,
+ &:disabled {
+ color: var(--#{$prefix}dropdown-link-disabled-color);
+ pointer-events: none;
+ background-color: transparent;
+ // Remove CSS gradients if they're enabled
+ background-image: if($enable-gradients, none, null);
+ }
+}
+
+.dropdown-menu.show {
+ display: block;
+}
+
+// Dropdown section headers
+.dropdown-header {
+ display: block;
+ padding: var(--#{$prefix}dropdown-header-padding-y) var(--#{$prefix}dropdown-header-padding-x);
+ margin-bottom: 0; // for use with heading elements
+ @include font-size($font-size-sm);
+ color: var(--#{$prefix}dropdown-header-color);
+ white-space: nowrap; // as with > li > a
+}
+
+// Dropdown text
+.dropdown-item-text {
+ display: block;
+ padding: var(--#{$prefix}dropdown-item-padding-y) var(--#{$prefix}dropdown-item-padding-x);
+ color: var(--#{$prefix}dropdown-link-color);
+}
+
+// Dark dropdowns
+.dropdown-menu-dark {
+ // scss-docs-start dropdown-dark-css-vars
+ --#{$prefix}dropdown-color: #{$dropdown-dark-color};
+ --#{$prefix}dropdown-bg: #{$dropdown-dark-bg};
+ --#{$prefix}dropdown-border-color: #{$dropdown-dark-border-color};
+ --#{$prefix}dropdown-box-shadow: #{$dropdown-dark-box-shadow};
+ --#{$prefix}dropdown-link-color: #{$dropdown-dark-link-color};
+ --#{$prefix}dropdown-link-hover-color: #{$dropdown-dark-link-hover-color};
+ --#{$prefix}dropdown-divider-bg: #{$dropdown-dark-divider-bg};
+ --#{$prefix}dropdown-link-hover-bg: #{$dropdown-dark-link-hover-bg};
+ --#{$prefix}dropdown-link-active-color: #{$dropdown-dark-link-active-color};
+ --#{$prefix}dropdown-link-active-bg: #{$dropdown-dark-link-active-bg};
+ --#{$prefix}dropdown-link-disabled-color: #{$dropdown-dark-link-disabled-color};
+ --#{$prefix}dropdown-header-color: #{$dropdown-dark-header-color};
+ // scss-docs-end dropdown-dark-css-vars
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_forms.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_forms.scss
new file mode 100644
index 0000000000..7b17d849ac
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_forms.scss
@@ -0,0 +1,9 @@
+@import "forms/labels";
+@import "forms/form-text";
+@import "forms/form-control";
+@import "forms/form-select";
+@import "forms/form-check";
+@import "forms/form-range";
+@import "forms/floating-labels";
+@import "forms/input-group";
+@import "forms/validation";
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_functions.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_functions.scss
new file mode 100644
index 0000000000..969a4b08bf
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_functions.scss
@@ -0,0 +1,302 @@
+// Bootstrap functions
+//
+// Utility mixins and functions for evaluating source code across our variables, maps, and mixins.
+
+// Ascending
+// Used to evaluate Sass maps like our grid breakpoints.
+@mixin _assert-ascending($map, $map-name) {
+ $prev-key: null;
+ $prev-num: null;
+ @each $key, $num in $map {
+ @if $prev-num == null or unit($num) == "%" or unit($prev-num) == "%" {
+ // Do nothing
+ } @else if not comparable($prev-num, $num) {
+ @warn "Potentially invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} whose unit makes it incomparable to #{$prev-num}, the value of the previous key '#{$prev-key}' !";
+ } @else if $prev-num >= $num {
+ @warn "Invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} which isn't greater than #{$prev-num}, the value of the previous key '#{$prev-key}' !";
+ }
+ $prev-key: $key;
+ $prev-num: $num;
+ }
+}
+
+// Starts at zero
+// Used to ensure the min-width of the lowest breakpoint starts at 0.
+@mixin _assert-starts-at-zero($map, $map-name: "$grid-breakpoints") {
+ @if length($map) > 0 {
+ $values: map-values($map);
+ $first-value: nth($values, 1);
+ @if $first-value != 0 {
+ @warn "First breakpoint in #{$map-name} must start at 0, but starts at #{$first-value}.";
+ }
+ }
+}
+
+// Colors
+@function to-rgb($value) {
+ @return red($value), green($value), blue($value);
+}
+
+// stylelint-disable scss/dollar-variable-pattern
+@function rgba-css-var($identifier, $target) {
+ @if $identifier == "body" and $target == "bg" {
+ @return rgba(var(--#{$prefix}#{$identifier}-bg-rgb), var(--#{$prefix}#{$target}-opacity));
+ } @if $identifier == "body" and $target == "text" {
+ @return rgba(var(--#{$prefix}#{$identifier}-color-rgb), var(--#{$prefix}#{$target}-opacity));
+ } @else {
+ @return rgba(var(--#{$prefix}#{$identifier}-rgb), var(--#{$prefix}#{$target}-opacity));
+ }
+}
+
+@function map-loop($map, $func, $args...) {
+ $_map: ();
+
+ @each $key, $value in $map {
+ // allow to pass the $key and $value of the map as an function argument
+ $_args: ();
+ @each $arg in $args {
+ $_args: append($_args, if($arg == "$key", $key, if($arg == "$value", $value, $arg)));
+ }
+
+ $_map: map-merge($_map, ($key: call(get-function($func), $_args...)));
+ }
+
+ @return $_map;
+}
+// stylelint-enable scss/dollar-variable-pattern
+
+@function varify($list) {
+ $result: null;
+ @each $entry in $list {
+ $result: append($result, var(--#{$prefix}#{$entry}), space);
+ }
+ @return $result;
+}
+
+// Internal Bootstrap function to turn maps into its negative variant.
+// It prefixes the keys with `n` and makes the value negative.
+@function negativify-map($map) {
+ $result: ();
+ @each $key, $value in $map {
+ @if $key != 0 {
+ $result: map-merge($result, ("n" + $key: (-$value)));
+ }
+ }
+ @return $result;
+}
+
+// Get multiple keys from a sass map
+@function map-get-multiple($map, $values) {
+ $result: ();
+ @each $key, $value in $map {
+ @if (index($values, $key) != null) {
+ $result: map-merge($result, ($key: $value));
+ }
+ }
+ @return $result;
+}
+
+// Merge multiple maps
+@function map-merge-multiple($maps...) {
+ $merged-maps: ();
+
+ @each $map in $maps {
+ $merged-maps: map-merge($merged-maps, $map);
+ }
+ @return $merged-maps;
+}
+
+// Replace `$search` with `$replace` in `$string`
+// Used on our SVG icon backgrounds for custom forms.
+//
+// @author Kitty Giraudel
+// @param {String} $string - Initial string
+// @param {String} $search - Substring to replace
+// @param {String} $replace ('') - New value
+// @return {String} - Updated string
+@function str-replace($string, $search, $replace: "") {
+ $index: str-index($string, $search);
+
+ @if $index {
+ @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
+ }
+
+ @return $string;
+}
+
+// See https://codepen.io/kevinweber/pen/dXWoRw
+//
+// Requires the use of quotes around data URIs.
+
+@function escape-svg($string) {
+ @if str-index($string, "data:image/svg+xml") {
+ @each $char, $encoded in $escaped-characters {
+ // Do not escape the url brackets
+ @if str-index($string, "url(") == 1 {
+ $string: url("#{str-replace(str-slice($string, 6, -3), $char, $encoded)}");
+ } @else {
+ $string: str-replace($string, $char, $encoded);
+ }
+ }
+ }
+
+ @return $string;
+}
+
+// Color contrast
+// See https://github.com/twbs/bootstrap/pull/30168
+
+// A list of pre-calculated numbers of pow(divide((divide($value, 255) + .055), 1.055), 2.4). (from 0 to 255)
+// stylelint-disable-next-line scss/dollar-variable-default, scss/dollar-variable-pattern
+$_luminance-list: .0008 .001 .0011 .0013 .0015 .0017 .002 .0022 .0025 .0027 .003 .0033 .0037 .004 .0044 .0048 .0052 .0056 .006 .0065 .007 .0075 .008 .0086 .0091 .0097 .0103 .011 .0116 .0123 .013 .0137 .0144 .0152 .016 .0168 .0176 .0185 .0194 .0203 .0212 .0222 .0232 .0242 .0252 .0262 .0273 .0284 .0296 .0307 .0319 .0331 .0343 .0356 .0369 .0382 .0395 .0409 .0423 .0437 .0452 .0467 .0482 .0497 .0513 .0529 .0545 .0561 .0578 .0595 .0612 .063 .0648 .0666 .0685 .0704 .0723 .0742 .0762 .0782 .0802 .0823 .0844 .0865 .0887 .0908 .0931 .0953 .0976 .0999 .1022 .1046 .107 .1095 .1119 .1144 .117 .1195 .1221 .1248 .1274 .1301 .1329 .1356 .1384 .1413 .1441 .147 .15 .1529 .1559 .159 .162 .1651 .1683 .1714 .1746 .1779 .1812 .1845 .1878 .1912 .1946 .1981 .2016 .2051 .2086 .2122 .2159 .2195 .2232 .227 .2307 .2346 .2384 .2423 .2462 .2502 .2542 .2582 .2623 .2664 .2705 .2747 .2789 .2831 .2874 .2918 .2961 .3005 .305 .3095 .314 .3185 .3231 .3278 .3325 .3372 .3419 .3467 .3515 .3564 .3613 .3663 .3712 .3763 .3813 .3864 .3916 .3968 .402 .4072 .4125 .4179 .4233 .4287 .4342 .4397 .4452 .4508 .4564 .4621 .4678 .4735 .4793 .4851 .491 .4969 .5029 .5089 .5149 .521 .5271 .5333 .5395 .5457 .552 .5583 .5647 .5711 .5776 .5841 .5906 .5972 .6038 .6105 .6172 .624 .6308 .6376 .6445 .6514 .6584 .6654 .6724 .6795 .6867 .6939 .7011 .7084 .7157 .7231 .7305 .7379 .7454 .7529 .7605 .7682 .7758 .7835 .7913 .7991 .807 .8148 .8228 .8308 .8388 .8469 .855 .8632 .8714 .8796 .8879 .8963 .9047 .9131 .9216 .9301 .9387 .9473 .956 .9647 .9734 .9823 .9911 1;
+
+@function color-contrast($background, $color-contrast-dark: $color-contrast-dark, $color-contrast-light: $color-contrast-light, $min-contrast-ratio: $min-contrast-ratio) {
+ $foregrounds: $color-contrast-light, $color-contrast-dark, $white, $black;
+ $max-ratio: 0;
+ $max-ratio-color: null;
+
+ @each $color in $foregrounds {
+ $contrast-ratio: contrast-ratio($background, $color);
+ @if $contrast-ratio > $min-contrast-ratio {
+ @return $color;
+ } @else if $contrast-ratio > $max-ratio {
+ $max-ratio: $contrast-ratio;
+ $max-ratio-color: $color;
+ }
+ }
+
+ @warn "Found no color leading to #{$min-contrast-ratio}:1 contrast ratio against #{$background}...";
+
+ @return $max-ratio-color;
+}
+
+@function contrast-ratio($background, $foreground: $color-contrast-light) {
+ $l1: luminance($background);
+ $l2: luminance(opaque($background, $foreground));
+
+ @return if($l1 > $l2, divide($l1 + .05, $l2 + .05), divide($l2 + .05, $l1 + .05));
+}
+
+// Return WCAG2.1 relative luminance
+// See https://www.w3.org/TR/WCAG/#dfn-relative-luminance
+// See https://www.w3.org/TR/WCAG/#dfn-contrast-ratio
+@function luminance($color) {
+ $rgb: (
+ "r": red($color),
+ "g": green($color),
+ "b": blue($color)
+ );
+
+ @each $name, $value in $rgb {
+ $value: if(divide($value, 255) < .03928, divide(divide($value, 255), 12.92), nth($_luminance-list, $value + 1));
+ $rgb: map-merge($rgb, ($name: $value));
+ }
+
+ @return (map-get($rgb, "r") * .2126) + (map-get($rgb, "g") * .7152) + (map-get($rgb, "b") * .0722);
+}
+
+// Return opaque color
+// opaque(#fff, rgba(0, 0, 0, .5)) => #808080
+@function opaque($background, $foreground) {
+ @return mix(rgba($foreground, 1), $background, opacity($foreground) * 100);
+}
+
+// scss-docs-start color-functions
+// Tint a color: mix a color with white
+@function tint-color($color, $weight) {
+ @return mix(white, $color, $weight);
+}
+
+// Shade a color: mix a color with black
+@function shade-color($color, $weight) {
+ @return mix(black, $color, $weight);
+}
+
+// Shade the color if the weight is positive, else tint it
+@function shift-color($color, $weight) {
+ @return if($weight > 0, shade-color($color, $weight), tint-color($color, -$weight));
+}
+// scss-docs-end color-functions
+
+// Return valid calc
+@function add($value1, $value2, $return-calc: true) {
+ @if $value1 == null {
+ @return $value2;
+ }
+
+ @if $value2 == null {
+ @return $value1;
+ }
+
+ @if type-of($value1) == number and type-of($value2) == number and comparable($value1, $value2) {
+ @return $value1 + $value2;
+ }
+
+ @return if($return-calc == true, calc(#{$value1} + #{$value2}), $value1 + unquote(" + ") + $value2);
+}
+
+@function subtract($value1, $value2, $return-calc: true) {
+ @if $value1 == null and $value2 == null {
+ @return null;
+ }
+
+ @if $value1 == null {
+ @return -$value2;
+ }
+
+ @if $value2 == null {
+ @return $value1;
+ }
+
+ @if type-of($value1) == number and type-of($value2) == number and comparable($value1, $value2) {
+ @return $value1 - $value2;
+ }
+
+ @if type-of($value2) != number {
+ $value2: unquote("(") + $value2 + unquote(")");
+ }
+
+ @return if($return-calc == true, calc(#{$value1} - #{$value2}), $value1 + unquote(" - ") + $value2);
+}
+
+@function divide($dividend, $divisor, $precision: 10) {
+ $sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);
+ $dividend: abs($dividend);
+ $divisor: abs($divisor);
+ @if $dividend == 0 {
+ @return 0;
+ }
+ @if $divisor == 0 {
+ @error "Cannot divide by 0";
+ }
+ $remainder: $dividend;
+ $result: 0;
+ $factor: 10;
+ @while ($remainder > 0 and $precision >= 0) {
+ $quotient: 0;
+ @while ($remainder >= $divisor) {
+ $remainder: $remainder - $divisor;
+ $quotient: $quotient + 1;
+ }
+ $result: $result * 10 + $quotient;
+ $factor: $factor * .1;
+ $remainder: $remainder * 10;
+ $precision: $precision - 1;
+ @if ($precision < 0 and $remainder >= $divisor * 5) {
+ $result: $result + 1;
+ }
+ }
+ $result: $result * $factor * $sign;
+ $dividend-unit: unit($dividend);
+ $divisor-unit: unit($divisor);
+ $unit-map: (
+ "px": 1px,
+ "rem": 1rem,
+ "em": 1em,
+ "%": 1%
+ );
+ @if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {
+ $result: $result * map-get($unit-map, $dividend-unit);
+ }
+ @return $result;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_grid.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_grid.scss
new file mode 100644
index 0000000000..0e0ba210ab
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_grid.scss
@@ -0,0 +1,33 @@
+// Row
+//
+// Rows contain your columns.
+
+@if $enable-grid-classes {
+ .row {
+ @include make-row();
+
+ > * {
+ @include make-col-ready();
+ }
+ }
+}
+
+@if $enable-cssgrid {
+ .grid {
+ display: grid;
+ grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);
+ grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);
+ gap: var(--#{$prefix}gap, #{$grid-gutter-width});
+
+ @include make-cssgrid();
+ }
+}
+
+
+// Columns
+//
+// Common styles for small and large grid columns
+
+@if $enable-grid-classes {
+ @include make-grid-columns();
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_helpers.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_helpers.scss
new file mode 100644
index 0000000000..644b693fbc
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_helpers.scss
@@ -0,0 +1,10 @@
+@import "helpers/clearfix";
+@import "helpers/color-bg";
+@import "helpers/colored-links";
+@import "helpers/ratio";
+@import "helpers/position";
+@import "helpers/stacks";
+@import "helpers/visually-hidden";
+@import "helpers/stretched-link";
+@import "helpers/text-truncation";
+@import "helpers/vr";
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_images.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_images.scss
new file mode 100644
index 0000000000..3d6a1014c4
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_images.scss
@@ -0,0 +1,42 @@
+// Responsive images (ensure images don't scale beyond their parents)
+//
+// This is purposefully opt-in via an explicit class rather than being the default for all `<img>`s.
+// We previously tried the "images are responsive by default" approach in Bootstrap v2,
+// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)
+// which weren't expecting the images within themselves to be involuntarily resized.
+// See also https://github.com/twbs/bootstrap/issues/18178
+.img-fluid {
+ @include img-fluid();
+}
+
+
+// Image thumbnails
+.img-thumbnail {
+ padding: $thumbnail-padding;
+ background-color: $thumbnail-bg;
+ border: $thumbnail-border-width solid $thumbnail-border-color;
+ @include border-radius($thumbnail-border-radius);
+ @include box-shadow($thumbnail-box-shadow);
+
+ // Keep them at most 100% wide
+ @include img-fluid();
+}
+
+//
+// Figures
+//
+
+.figure {
+ // Ensures the caption's text aligns with the image.
+ display: inline-block;
+}
+
+.figure-img {
+ margin-bottom: $spacer * .5;
+ line-height: 1;
+}
+
+.figure-caption {
+ @include font-size($figure-caption-font-size);
+ color: $figure-caption-color;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_list-group.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_list-group.scss
new file mode 100644
index 0000000000..c0ec16468d
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_list-group.scss
@@ -0,0 +1,192 @@
+// Base class
+//
+// Easily usable on <ul>, <ol>, or <div>.
+
+.list-group {
+ // scss-docs-start list-group-css-vars
+ --#{$prefix}list-group-color: #{$list-group-color};
+ --#{$prefix}list-group-bg: #{$list-group-bg};
+ --#{$prefix}list-group-border-color: #{$list-group-border-color};
+ --#{$prefix}list-group-border-width: #{$list-group-border-width};
+ --#{$prefix}list-group-border-radius: #{$list-group-border-radius};
+ --#{$prefix}list-group-item-padding-x: #{$list-group-item-padding-x};
+ --#{$prefix}list-group-item-padding-y: #{$list-group-item-padding-y};
+ --#{$prefix}list-group-action-color: #{$list-group-action-color};
+ --#{$prefix}list-group-action-hover-color: #{$list-group-action-hover-color};
+ --#{$prefix}list-group-action-hover-bg: #{$list-group-hover-bg};
+ --#{$prefix}list-group-action-active-color: #{$list-group-action-active-color};
+ --#{$prefix}list-group-action-active-bg: #{$list-group-action-active-bg};
+ --#{$prefix}list-group-disabled-color: #{$list-group-disabled-color};
+ --#{$prefix}list-group-disabled-bg: #{$list-group-disabled-bg};
+ --#{$prefix}list-group-active-color: #{$list-group-active-color};
+ --#{$prefix}list-group-active-bg: #{$list-group-active-bg};
+ --#{$prefix}list-group-active-border-color: #{$list-group-active-border-color};
+ // scss-docs-end list-group-css-vars
+
+ display: flex;
+ flex-direction: column;
+
+ // No need to set list-style: none; since .list-group-item is block level
+ padding-left: 0; // reset padding because ul and ol
+ margin-bottom: 0;
+ @include border-radius(var(--#{$prefix}list-group-border-radius));
+}
+
+.list-group-numbered {
+ list-style-type: none;
+ counter-reset: section;
+
+ > .list-group-item::before {
+ // Increments only this instance of the section counter
+ content: counters(section, ".") ". ";
+ counter-increment: section;
+ }
+}
+
+// Interactive list items
+//
+// Use anchor or button elements instead of `li`s or `div`s to create interactive
+// list items. Includes an extra `.active` modifier class for selected items.
+
+.list-group-item-action {
+ width: 100%; // For `<button>`s (anchors become 100% by default though)
+ color: var(--#{$prefix}list-group-action-color);
+ text-align: inherit; // For `<button>`s (anchors inherit)
+
+ // Hover state
+ &:hover,
+ &:focus {
+ z-index: 1; // Place hover/focus items above their siblings for proper border styling
+ color: var(--#{$prefix}list-group-action-hover-color);
+ text-decoration: none;
+ background-color: var(--#{$prefix}list-group-action-hover-bg);
+ }
+
+ &:active {
+ color: var(--#{$prefix}list-group-action-active-color);
+ background-color: var(--#{$prefix}list-group-action-active-bg);
+ }
+}
+
+// Individual list items
+//
+// Use on `li`s or `div`s within the `.list-group` parent.
+
+.list-group-item {
+ position: relative;
+ display: block;
+ padding: var(--#{$prefix}list-group-item-padding-y) var(--#{$prefix}list-group-item-padding-x);
+ color: var(--#{$prefix}list-group-color);
+ text-decoration: if($link-decoration == none, null, none);
+ background-color: var(--#{$prefix}list-group-bg);
+ border: var(--#{$prefix}list-group-border-width) solid var(--#{$prefix}list-group-border-color);
+
+ &:first-child {
+ @include border-top-radius(inherit);
+ }
+
+ &:last-child {
+ @include border-bottom-radius(inherit);
+ }
+
+ &.disabled,
+ &:disabled {
+ color: var(--#{$prefix}list-group-disabled-color);
+ pointer-events: none;
+ background-color: var(--#{$prefix}list-group-disabled-bg);
+ }
+
+ // Include both here for `<a>`s and `<button>`s
+ &.active {
+ z-index: 2; // Place active items above their siblings for proper border styling
+ color: var(--#{$prefix}list-group-active-color);
+ background-color: var(--#{$prefix}list-group-active-bg);
+ border-color: var(--#{$prefix}list-group-active-border-color);
+ }
+
+ // stylelint-disable-next-line scss/selector-no-redundant-nesting-selector
+ & + .list-group-item {
+ border-top-width: 0;
+
+ &.active {
+ margin-top: calc(-1 * var(--#{$prefix}list-group-border-width)); // stylelint-disable-line function-disallowed-list
+ border-top-width: var(--#{$prefix}list-group-border-width);
+ }
+ }
+}
+
+// Horizontal
+//
+// Change the layout of list group items from vertical (default) to horizontal.
+
+@each $breakpoint in map-keys($grid-breakpoints) {
+ @include media-breakpoint-up($breakpoint) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+
+ .list-group-horizontal#{$infix} {
+ flex-direction: row;
+
+ > .list-group-item {
+ &:first-child:not(:last-child) {
+ @include border-bottom-start-radius(var(--#{$prefix}list-group-border-radius));
+ @include border-top-end-radius(0);
+ }
+
+ &:last-child:not(:first-child) {
+ @include border-top-end-radius(var(--#{$prefix}list-group-border-radius));
+ @include border-bottom-start-radius(0);
+ }
+
+ &.active {
+ margin-top: 0;
+ }
+
+ + .list-group-item {
+ border-top-width: var(--#{$prefix}list-group-border-width);
+ border-left-width: 0;
+
+ &.active {
+ margin-left: calc(-1 * var(--#{$prefix}list-group-border-width)); // stylelint-disable-line function-disallowed-list
+ border-left-width: var(--#{$prefix}list-group-border-width);
+ }
+ }
+ }
+ }
+ }
+}
+
+
+// Flush list items
+//
+// Remove borders and border-radius to keep list group items edge-to-edge. Most
+// useful within other components (e.g., cards).
+
+.list-group-flush {
+ @include border-radius(0);
+
+ > .list-group-item {
+ border-width: 0 0 var(--#{$prefix}list-group-border-width);
+
+ &:last-child {
+ border-bottom-width: 0;
+ }
+ }
+}
+
+
+// scss-docs-start list-group-modifiers
+// List group contextual variants
+//
+// Add modifier classes to change text and background color on individual items.
+// Organizationally, this must come after the `:hover` states.
+
+@each $state, $value in $theme-colors {
+ $list-group-variant-bg: shift-color($value, $list-group-item-bg-scale);
+ $list-group-variant-color: shift-color($value, $list-group-item-color-scale);
+ @if (contrast-ratio($list-group-variant-bg, $list-group-variant-color) < $min-contrast-ratio) {
+ $list-group-variant-color: mix($value, color-contrast($list-group-variant-bg), abs($list-group-item-color-scale));
+ }
+
+ @include list-group-item-variant($state, $list-group-variant-bg, $list-group-variant-color);
+}
+// scss-docs-end list-group-modifiers
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_maps.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_maps.scss
new file mode 100644
index 0000000000..2770a67615
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_maps.scss
@@ -0,0 +1,54 @@
+// Re-assigned maps
+//
+// Placed here so that others can override the default Sass maps and see automatic updates to utilities and more.
+
+// scss-docs-start theme-colors-rgb
+$theme-colors-rgb: map-loop($theme-colors, to-rgb, "$value") !default;
+// scss-docs-end theme-colors-rgb
+
+// Utilities maps
+//
+// Extends the default `$theme-colors` maps to help create our utilities.
+
+// Come v6, we'll de-dupe these variables. Until then, for backward compatibility, we keep them to reassign.
+// scss-docs-start utilities-colors
+$utilities-colors: $theme-colors-rgb !default;
+// scss-docs-end utilities-colors
+
+// scss-docs-start utilities-text-colors
+$utilities-text: map-merge(
+ $utilities-colors,
+ (
+ "black": to-rgb($black),
+ "white": to-rgb($white),
+ "body": to-rgb($body-color)
+ )
+) !default;
+$utilities-text-colors: map-loop($utilities-text, rgba-css-var, "$key", "text") !default;
+// scss-docs-end utilities-text-colors
+
+// scss-docs-start utilities-bg-colors
+$utilities-bg: map-merge(
+ $utilities-colors,
+ (
+ "black": to-rgb($black),
+ "white": to-rgb($white),
+ "body": to-rgb($body-bg)
+ )
+) !default;
+$utilities-bg-colors: map-loop($utilities-bg, rgba-css-var, "$key", "bg") !default;
+// scss-docs-end utilities-bg-colors
+
+// scss-docs-start utilities-border-colors
+$utilities-border: map-merge(
+ $utilities-colors,
+ (
+ "white": to-rgb($white)
+ )
+) !default;
+$utilities-border-colors: map-loop($utilities-border, rgba-css-var, "$key", "border") !default;
+// scss-docs-end utilities-border-colors
+
+$negative-spacers: if($enable-negative-margins, negativify-map($spacers), null) !default;
+
+$gutters: $spacers !default;
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_mixins.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_mixins.scss
new file mode 100644
index 0000000000..af1f74f72e
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_mixins.scss
@@ -0,0 +1,43 @@
+// Toggles
+//
+// Used in conjunction with global variables to enable certain theme features.
+
+// Vendor
+@import "vendor/rfs";
+
+// Deprecate
+@import "mixins/deprecate";
+
+// Helpers
+@import "mixins/breakpoints";
+@import "mixins/color-scheme";
+@import "mixins/image";
+@import "mixins/resize";
+@import "mixins/visually-hidden";
+@import "mixins/reset-text";
+@import "mixins/text-truncate";
+
+// Utilities
+@import "mixins/utilities";
+
+// Components
+@import "mixins/alert";
+@import "mixins/backdrop";
+@import "mixins/buttons";
+@import "mixins/caret";
+@import "mixins/pagination";
+@import "mixins/lists";
+@import "mixins/list-group";
+@import "mixins/forms";
+@import "mixins/table-variants";
+
+// Skins
+@import "mixins/border-radius";
+@import "mixins/box-shadow";
+@import "mixins/gradients";
+@import "mixins/transition";
+
+// Layout
+@import "mixins/clearfix";
+@import "mixins/container";
+@import "mixins/grid";
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_modal.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_modal.scss
new file mode 100644
index 0000000000..5f1429fe4b
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_modal.scss
@@ -0,0 +1,237 @@
+// stylelint-disable function-disallowed-list
+
+// .modal-open - body class for killing the scroll
+// .modal - container to scroll within
+// .modal-dialog - positioning shell for the actual modal
+// .modal-content - actual modal w/ bg and corners and stuff
+
+
+// Container that the modal scrolls within
+.modal {
+ // scss-docs-start modal-css-vars
+ --#{$prefix}modal-zindex: #{$zindex-modal};
+ --#{$prefix}modal-width: #{$modal-md};
+ --#{$prefix}modal-padding: #{$modal-inner-padding};
+ --#{$prefix}modal-margin: #{$modal-dialog-margin};
+ --#{$prefix}modal-color: #{$modal-content-color};
+ --#{$prefix}modal-bg: #{$modal-content-bg};
+ --#{$prefix}modal-border-color: #{$modal-content-border-color};
+ --#{$prefix}modal-border-width: #{$modal-content-border-width};
+ --#{$prefix}modal-border-radius: #{$modal-content-border-radius};
+ --#{$prefix}modal-box-shadow: #{$modal-content-box-shadow-xs};
+ --#{$prefix}modal-inner-border-radius: #{$modal-content-inner-border-radius};
+ --#{$prefix}modal-header-padding-x: #{$modal-header-padding-x};
+ --#{$prefix}modal-header-padding-y: #{$modal-header-padding-y};
+ --#{$prefix}modal-header-padding: #{$modal-header-padding}; // Todo in v6: Split this padding into x and y
+ --#{$prefix}modal-header-border-color: #{$modal-header-border-color};
+ --#{$prefix}modal-header-border-width: #{$modal-header-border-width};
+ --#{$prefix}modal-title-line-height: #{$modal-title-line-height};
+ --#{$prefix}modal-footer-gap: #{$modal-footer-margin-between};
+ --#{$prefix}modal-footer-bg: #{$modal-footer-bg};
+ --#{$prefix}modal-footer-border-color: #{$modal-footer-border-color};
+ --#{$prefix}modal-footer-border-width: #{$modal-footer-border-width};
+ // scss-docs-end modal-css-vars
+
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: var(--#{$prefix}modal-zindex);
+ display: none;
+ width: 100%;
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: auto;
+ // Prevent Chrome on Windows from adding a focus outline. For details, see
+ // https://github.com/twbs/bootstrap/pull/10951.
+ outline: 0;
+ // We deliberately don't use `-webkit-overflow-scrolling: touch;` due to a
+ // gnarly iOS Safari bug: https://bugs.webkit.org/show_bug.cgi?id=158342
+ // See also https://github.com/twbs/bootstrap/issues/17695
+}
+
+// Shell div to position the modal with bottom padding
+.modal-dialog {
+ position: relative;
+ width: auto;
+ margin: var(--#{$prefix}modal-margin);
+ // allow clicks to pass through for custom click handling to close modal
+ pointer-events: none;
+
+ // When fading in the modal, animate it to slide down
+ .modal.fade & {
+ @include transition($modal-transition);
+ transform: $modal-fade-transform;
+ }
+ .modal.show & {
+ transform: $modal-show-transform;
+ }
+
+ // When trying to close, animate focus to scale
+ .modal.modal-static & {
+ transform: $modal-scale-transform;
+ }
+}
+
+.modal-dialog-scrollable {
+ height: calc(100% - var(--#{$prefix}modal-margin) * 2);
+
+ .modal-content {
+ max-height: 100%;
+ overflow: hidden;
+ }
+
+ .modal-body {
+ overflow-y: auto;
+ }
+}
+
+.modal-dialog-centered {
+ display: flex;
+ align-items: center;
+ min-height: calc(100% - var(--#{$prefix}modal-margin) * 2);
+}
+
+// Actual modal
+.modal-content {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ width: 100%; // Ensure `.modal-content` extends the full width of the parent `.modal-dialog`
+ // counteract the pointer-events: none; in the .modal-dialog
+ color: var(--#{$prefix}modal-color);
+ pointer-events: auto;
+ background-color: var(--#{$prefix}modal-bg);
+ background-clip: padding-box;
+ border: var(--#{$prefix}modal-border-width) solid var(--#{$prefix}modal-border-color);
+ @include border-radius(var(--#{$prefix}modal-border-radius));
+ @include box-shadow(var(--#{$prefix}modal-box-shadow));
+ // Remove focus outline from opened modal
+ outline: 0;
+}
+
+// Modal background
+.modal-backdrop {
+ // scss-docs-start modal-backdrop-css-vars
+ --#{$prefix}backdrop-zindex: #{$zindex-modal-backdrop};
+ --#{$prefix}backdrop-bg: #{$modal-backdrop-bg};
+ --#{$prefix}backdrop-opacity: #{$modal-backdrop-opacity};
+ // scss-docs-end modal-backdrop-css-vars
+
+ @include overlay-backdrop(var(--#{$prefix}backdrop-zindex), var(--#{$prefix}backdrop-bg), var(--#{$prefix}backdrop-opacity));
+}
+
+// Modal header
+// Top section of the modal w/ title and dismiss
+.modal-header {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+ justify-content: space-between; // Put modal header elements (title and dismiss) on opposite ends
+ padding: var(--#{$prefix}modal-header-padding);
+ border-bottom: var(--#{$prefix}modal-header-border-width) solid var(--#{$prefix}modal-header-border-color);
+ @include border-top-radius(var(--#{$prefix}modal-inner-border-radius));
+
+ .btn-close {
+ padding: calc(var(--#{$prefix}modal-header-padding-y) * .5) calc(var(--#{$prefix}modal-header-padding-x) * .5);
+ margin: calc(-.5 * var(--#{$prefix}modal-header-padding-y)) calc(-.5 * var(--#{$prefix}modal-header-padding-x)) calc(-.5 * var(--#{$prefix}modal-header-padding-y)) auto;
+ }
+}
+
+// Title text within header
+.modal-title {
+ margin-bottom: 0;
+ line-height: var(--#{$prefix}modal-title-line-height);
+}
+
+// Modal body
+// Where all modal content resides (sibling of .modal-header and .modal-footer)
+.modal-body {
+ position: relative;
+ // Enable `flex-grow: 1` so that the body take up as much space as possible
+ // when there should be a fixed height on `.modal-dialog`.
+ flex: 1 1 auto;
+ padding: var(--#{$prefix}modal-padding);
+}
+
+// Footer (for actions)
+.modal-footer {
+ display: flex;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+ align-items: center; // vertically center
+ justify-content: flex-end; // Right align buttons with flex property because text-align doesn't work on flex items
+ padding: calc(var(--#{$prefix}modal-padding) - var(--#{$prefix}modal-footer-gap) * .5);
+ background-color: var(--#{$prefix}modal-footer-bg);
+ border-top: var(--#{$prefix}modal-footer-border-width) solid var(--#{$prefix}modal-footer-border-color);
+ @include border-bottom-radius(var(--#{$prefix}modal-inner-border-radius));
+
+ // Place margin between footer elements
+ // This solution is far from ideal because of the universal selector usage,
+ // but is needed to fix https://github.com/twbs/bootstrap/issues/24800
+ > * {
+ margin: calc(var(--#{$prefix}modal-footer-gap) * .5); // Todo in v6: replace with gap on parent class
+ }
+}
+
+// Scale up the modal
+@include media-breakpoint-up(sm) {
+ .modal {
+ --#{$prefix}modal-margin: #{$modal-dialog-margin-y-sm-up};
+ --#{$prefix}modal-box-shadow: #{$modal-content-box-shadow-sm-up};
+ }
+
+ // Automatically set modal's width for larger viewports
+ .modal-dialog {
+ max-width: var(--#{$prefix}modal-width);
+ margin-right: auto;
+ margin-left: auto;
+ }
+
+ .modal-sm {
+ --#{$prefix}modal-width: #{$modal-sm};
+ }
+}
+
+@include media-breakpoint-up(lg) {
+ .modal-lg,
+ .modal-xl {
+ --#{$prefix}modal-width: #{$modal-lg};
+ }
+}
+
+@include media-breakpoint-up(xl) {
+ .modal-xl {
+ --#{$prefix}modal-width: #{$modal-xl};
+ }
+}
+
+// scss-docs-start modal-fullscreen-loop
+@each $breakpoint in map-keys($grid-breakpoints) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+ $postfix: if($infix != "", $infix + "-down", "");
+
+ @include media-breakpoint-down($breakpoint) {
+ .modal-fullscreen#{$postfix} {
+ width: 100vw;
+ max-width: none;
+ height: 100%;
+ margin: 0;
+
+ .modal-content {
+ height: 100%;
+ border: 0;
+ @include border-radius(0);
+ }
+
+ .modal-header,
+ .modal-footer {
+ @include border-radius(0);
+ }
+
+ .modal-body {
+ overflow-y: auto;
+ }
+ }
+ }
+}
+// scss-docs-end modal-fullscreen-loop
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_nav.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_nav.scss
new file mode 100644
index 0000000000..9efc03bc8f
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_nav.scss
@@ -0,0 +1,172 @@
+// Base class
+//
+// Kickstart any navigation component with a set of style resets. Works with
+// `<nav>`s, `<ul>`s or `<ol>`s.
+
+.nav {
+ // scss-docs-start nav-css-vars
+ --#{$prefix}nav-link-padding-x: #{$nav-link-padding-x};
+ --#{$prefix}nav-link-padding-y: #{$nav-link-padding-y};
+ @include rfs($nav-link-font-size, --#{$prefix}nav-link-font-size);
+ --#{$prefix}nav-link-font-weight: #{$nav-link-font-weight};
+ --#{$prefix}nav-link-color: #{$nav-link-color};
+ --#{$prefix}nav-link-hover-color: #{$nav-link-hover-color};
+ --#{$prefix}nav-link-disabled-color: #{$nav-link-disabled-color};
+ // scss-docs-end nav-css-vars
+
+ display: flex;
+ flex-wrap: wrap;
+ padding-left: 0;
+ margin-bottom: 0;
+ list-style: none;
+}
+
+.nav-link {
+ display: block;
+ padding: var(--#{$prefix}nav-link-padding-y) var(--#{$prefix}nav-link-padding-x);
+ @include font-size(var(--#{$prefix}nav-link-font-size));
+ font-weight: var(--#{$prefix}nav-link-font-weight);
+ color: var(--#{$prefix}nav-link-color);
+ text-decoration: if($link-decoration == none, null, none);
+ @include transition($nav-link-transition);
+
+ &:hover,
+ &:focus {
+ color: var(--#{$prefix}nav-link-hover-color);
+ text-decoration: if($link-hover-decoration == underline, none, null);
+ }
+
+ // Disabled state lightens text
+ &.disabled {
+ color: var(--#{$prefix}nav-link-disabled-color);
+ pointer-events: none;
+ cursor: default;
+ }
+}
+
+//
+// Tabs
+//
+
+.nav-tabs {
+ // scss-docs-start nav-tabs-css-vars
+ --#{$prefix}nav-tabs-border-width: #{$nav-tabs-border-width};
+ --#{$prefix}nav-tabs-border-color: #{$nav-tabs-border-color};
+ --#{$prefix}nav-tabs-border-radius: #{$nav-tabs-border-radius};
+ --#{$prefix}nav-tabs-link-hover-border-color: #{$nav-tabs-link-hover-border-color};
+ --#{$prefix}nav-tabs-link-active-color: #{$nav-tabs-link-active-color};
+ --#{$prefix}nav-tabs-link-active-bg: #{$nav-tabs-link-active-bg};
+ --#{$prefix}nav-tabs-link-active-border-color: #{$nav-tabs-link-active-border-color};
+ // scss-docs-end nav-tabs-css-vars
+
+ border-bottom: var(--#{$prefix}nav-tabs-border-width) solid var(--#{$prefix}nav-tabs-border-color);
+
+ .nav-link {
+ margin-bottom: calc(-1 * var(--#{$prefix}nav-tabs-border-width)); // stylelint-disable-line function-disallowed-list
+ background: none;
+ border: var(--#{$prefix}nav-tabs-border-width) solid transparent;
+ @include border-top-radius(var(--#{$prefix}nav-tabs-border-radius));
+
+ &:hover,
+ &:focus {
+ // Prevents active .nav-link tab overlapping focus outline of previous/next .nav-link
+ isolation: isolate;
+ border-color: var(--#{$prefix}nav-tabs-link-hover-border-color);
+ }
+
+ &.disabled,
+ &:disabled {
+ color: var(--#{$prefix}nav-link-disabled-color);
+ background-color: transparent;
+ border-color: transparent;
+ }
+ }
+
+ .nav-link.active,
+ .nav-item.show .nav-link {
+ color: var(--#{$prefix}nav-tabs-link-active-color);
+ background-color: var(--#{$prefix}nav-tabs-link-active-bg);
+ border-color: var(--#{$prefix}nav-tabs-link-active-border-color);
+ }
+
+ .dropdown-menu {
+ // Make dropdown border overlap tab border
+ margin-top: calc(-1 * var(--#{$prefix}nav-tabs-border-width)); // stylelint-disable-line function-disallowed-list
+ // Remove the top rounded corners here since there is a hard edge above the menu
+ @include border-top-radius(0);
+ }
+}
+
+
+//
+// Pills
+//
+
+.nav-pills {
+ // scss-docs-start nav-pills-css-vars
+ --#{$prefix}nav-pills-border-radius: #{$nav-pills-border-radius};
+ --#{$prefix}nav-pills-link-active-color: #{$nav-pills-link-active-color};
+ --#{$prefix}nav-pills-link-active-bg: #{$nav-pills-link-active-bg};
+ // scss-docs-end nav-pills-css-vars
+
+ .nav-link {
+ background: none;
+ border: 0;
+ @include border-radius(var(--#{$prefix}nav-pills-border-radius));
+
+ &:disabled {
+ color: var(--#{$prefix}nav-link-disabled-color);
+ background-color: transparent;
+ border-color: transparent;
+ }
+ }
+
+ .nav-link.active,
+ .show > .nav-link {
+ color: var(--#{$prefix}nav-pills-link-active-color);
+ @include gradient-bg(var(--#{$prefix}nav-pills-link-active-bg));
+ }
+}
+
+
+//
+// Justified variants
+//
+
+.nav-fill {
+ > .nav-link,
+ .nav-item {
+ flex: 1 1 auto;
+ text-align: center;
+ }
+}
+
+.nav-justified {
+ > .nav-link,
+ .nav-item {
+ flex-basis: 0;
+ flex-grow: 1;
+ text-align: center;
+ }
+}
+
+.nav-fill,
+.nav-justified {
+ .nav-item .nav-link {
+ width: 100%; // Make sure button will grow
+ }
+}
+
+
+// Tabbable tabs
+//
+// Hide tabbable panes to start, show them when `.active`
+
+.tab-content {
+ > .tab-pane {
+ display: none;
+ }
+ > .active {
+ display: block;
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_navbar.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_navbar.scss
new file mode 100644
index 0000000000..599b055ebc
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_navbar.scss
@@ -0,0 +1,278 @@
+// Navbar
+//
+// Provide a static navbar from which we expand to create full-width, fixed, and
+// other navbar variations.
+
+.navbar {
+ // scss-docs-start navbar-css-vars
+ --#{$prefix}navbar-padding-x: #{if($navbar-padding-x == null, 0, $navbar-padding-x)};
+ --#{$prefix}navbar-padding-y: #{$navbar-padding-y};
+ --#{$prefix}navbar-color: #{$navbar-light-color};
+ --#{$prefix}navbar-hover-color: #{$navbar-light-hover-color};
+ --#{$prefix}navbar-disabled-color: #{$navbar-light-disabled-color};
+ --#{$prefix}navbar-active-color: #{$navbar-light-active-color};
+ --#{$prefix}navbar-brand-padding-y: #{$navbar-brand-padding-y};
+ --#{$prefix}navbar-brand-margin-end: #{$navbar-brand-margin-end};
+ --#{$prefix}navbar-brand-font-size: #{$navbar-brand-font-size};
+ --#{$prefix}navbar-brand-color: #{$navbar-light-brand-color};
+ --#{$prefix}navbar-brand-hover-color: #{$navbar-light-brand-hover-color};
+ --#{$prefix}navbar-nav-link-padding-x: #{$navbar-nav-link-padding-x};
+ --#{$prefix}navbar-toggler-padding-y: #{$navbar-toggler-padding-y};
+ --#{$prefix}navbar-toggler-padding-x: #{$navbar-toggler-padding-x};
+ --#{$prefix}navbar-toggler-font-size: #{$navbar-toggler-font-size};
+ --#{$prefix}navbar-toggler-icon-bg: #{escape-svg($navbar-light-toggler-icon-bg)};
+ --#{$prefix}navbar-toggler-border-color: #{$navbar-light-toggler-border-color};
+ --#{$prefix}navbar-toggler-border-radius: #{$navbar-toggler-border-radius};
+ --#{$prefix}navbar-toggler-focus-width: #{$navbar-toggler-focus-width};
+ --#{$prefix}navbar-toggler-transition: #{$navbar-toggler-transition};
+ // scss-docs-end navbar-css-vars
+
+ position: relative;
+ display: flex;
+ flex-wrap: wrap; // allow us to do the line break for collapsing content
+ align-items: center;
+ justify-content: space-between; // space out brand from logo
+ padding: var(--#{$prefix}navbar-padding-y) var(--#{$prefix}navbar-padding-x);
+ @include gradient-bg();
+
+ // Because flex properties aren't inherited, we need to redeclare these first
+ // few properties so that content nested within behave properly.
+ // The `flex-wrap` property is inherited to simplify the expanded navbars
+ %container-flex-properties {
+ display: flex;
+ flex-wrap: inherit;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ > .container,
+ > .container-fluid {
+ @extend %container-flex-properties;
+ }
+
+ @each $breakpoint, $container-max-width in $container-max-widths {
+ > .container#{breakpoint-infix($breakpoint, $container-max-widths)} {
+ @extend %container-flex-properties;
+ }
+ }
+}
+
+
+// Navbar brand
+//
+// Used for brand, project, or site names.
+
+.navbar-brand {
+ padding-top: var(--#{$prefix}navbar-brand-padding-y);
+ padding-bottom: var(--#{$prefix}navbar-brand-padding-y);
+ margin-right: var(--#{$prefix}navbar-brand-margin-end);
+ @include font-size(var(--#{$prefix}navbar-brand-font-size));
+ color: var(--#{$prefix}navbar-brand-color);
+ text-decoration: if($link-decoration == none, null, none);
+ white-space: nowrap;
+
+ &:hover,
+ &:focus {
+ color: var(--#{$prefix}navbar-brand-hover-color);
+ text-decoration: if($link-hover-decoration == underline, none, null);
+ }
+}
+
+
+// Navbar nav
+//
+// Custom navbar navigation (doesn't require `.nav`, but does make use of `.nav-link`).
+
+.navbar-nav {
+ // scss-docs-start navbar-nav-css-vars
+ --#{$prefix}nav-link-padding-x: 0;
+ --#{$prefix}nav-link-padding-y: #{$nav-link-padding-y};
+ @include rfs($nav-link-font-size, --#{$prefix}nav-link-font-size);
+ --#{$prefix}nav-link-font-weight: #{$nav-link-font-weight};
+ --#{$prefix}nav-link-color: var(--#{$prefix}navbar-color);
+ --#{$prefix}nav-link-hover-color: var(--#{$prefix}navbar-hover-color);
+ --#{$prefix}nav-link-disabled-color: var(--#{$prefix}navbar-disabled-color);
+ // scss-docs-end navbar-nav-css-vars
+
+ display: flex;
+ flex-direction: column; // cannot use `inherit` to get the `.navbar`s value
+ padding-left: 0;
+ margin-bottom: 0;
+ list-style: none;
+
+ .show > .nav-link,
+ .nav-link.active {
+ color: var(--#{$prefix}navbar-active-color);
+ }
+
+ .dropdown-menu {
+ position: static;
+ }
+}
+
+
+// Navbar text
+//
+//
+
+.navbar-text {
+ padding-top: $nav-link-padding-y;
+ padding-bottom: $nav-link-padding-y;
+ color: var(--#{$prefix}navbar-color);
+
+ a,
+ a:hover,
+ a:focus {
+ color: var(--#{$prefix}navbar-active-color);
+ }
+}
+
+
+// Responsive navbar
+//
+// Custom styles for responsive collapsing and toggling of navbar contents.
+// Powered by the collapse Bootstrap JavaScript plugin.
+
+// When collapsed, prevent the toggleable navbar contents from appearing in
+// the default flexbox row orientation. Requires the use of `flex-wrap: wrap`
+// on the `.navbar` parent.
+.navbar-collapse {
+ flex-basis: 100%;
+ flex-grow: 1;
+ // For always expanded or extra full navbars, ensure content aligns itself
+ // properly vertically. Can be easily overridden with flex utilities.
+ align-items: center;
+}
+
+// Button for toggling the navbar when in its collapsed state
+.navbar-toggler {
+ padding: var(--#{$prefix}navbar-toggler-padding-y) var(--#{$prefix}navbar-toggler-padding-x);
+ @include font-size(var(--#{$prefix}navbar-toggler-font-size));
+ line-height: 1;
+ color: var(--#{$prefix}navbar-color);
+ background-color: transparent; // remove default button style
+ border: var(--#{$prefix}border-width) solid var(--#{$prefix}navbar-toggler-border-color); // remove default button style
+ @include border-radius(var(--#{$prefix}navbar-toggler-border-radius));
+ @include transition(var(--#{$prefix}navbar-toggler-transition));
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ &:focus {
+ text-decoration: none;
+ outline: 0;
+ box-shadow: 0 0 0 var(--#{$prefix}navbar-toggler-focus-width);
+ }
+}
+
+// Keep as a separate element so folks can easily override it with another icon
+// or image file as needed.
+.navbar-toggler-icon {
+ display: inline-block;
+ width: 1.5em;
+ height: 1.5em;
+ vertical-align: middle;
+ background-image: var(--#{$prefix}navbar-toggler-icon-bg);
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: 100%;
+}
+
+.navbar-nav-scroll {
+ max-height: var(--#{$prefix}scroll-height, 75vh);
+ overflow-y: auto;
+}
+
+// scss-docs-start navbar-expand-loop
+// Generate series of `.navbar-expand-*` responsive classes for configuring
+// where your navbar collapses.
+.navbar-expand {
+ @each $breakpoint in map-keys($grid-breakpoints) {
+ $next: breakpoint-next($breakpoint, $grid-breakpoints);
+ $infix: breakpoint-infix($next, $grid-breakpoints);
+
+ // stylelint-disable-next-line scss/selector-no-union-class-name
+ &#{$infix} {
+ @include media-breakpoint-up($next) {
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+
+ .navbar-nav {
+ flex-direction: row;
+
+ .dropdown-menu {
+ position: absolute;
+ }
+
+ .nav-link {
+ padding-right: var(--#{$prefix}navbar-nav-link-padding-x);
+ padding-left: var(--#{$prefix}navbar-nav-link-padding-x);
+ }
+ }
+
+ .navbar-nav-scroll {
+ overflow: visible;
+ }
+
+ .navbar-collapse {
+ display: flex !important; // stylelint-disable-line declaration-no-important
+ flex-basis: auto;
+ }
+
+ .navbar-toggler {
+ display: none;
+ }
+
+ .offcanvas {
+ // stylelint-disable declaration-no-important
+ position: static;
+ z-index: auto;
+ flex-grow: 1;
+ width: auto !important;
+ height: auto !important;
+ visibility: visible !important;
+ background-color: transparent !important;
+ border: 0 !important;
+ transform: none !important;
+ @include box-shadow(none);
+ @include transition(none);
+ // stylelint-enable declaration-no-important
+
+ .offcanvas-header {
+ display: none;
+ }
+
+ .offcanvas-body {
+ display: flex;
+ flex-grow: 0;
+ padding: 0;
+ overflow-y: visible;
+ }
+ }
+ }
+ }
+ }
+}
+// scss-docs-end navbar-expand-loop
+
+// Navbar themes
+//
+// Styles for switching between navbars with light or dark background.
+
+.navbar-light {
+ @include deprecate("`.navbar-light`", "v5.2.0", "v6.0.0", true);
+}
+
+.navbar-dark {
+ // scss-docs-start navbar-dark-css-vars
+ --#{$prefix}navbar-color: #{$navbar-dark-color};
+ --#{$prefix}navbar-hover-color: #{$navbar-dark-hover-color};
+ --#{$prefix}navbar-disabled-color: #{$navbar-dark-disabled-color};
+ --#{$prefix}navbar-active-color: #{$navbar-dark-active-color};
+ --#{$prefix}navbar-brand-color: #{$navbar-dark-brand-color};
+ --#{$prefix}navbar-brand-hover-color: #{$navbar-dark-brand-hover-color};
+ --#{$prefix}navbar-toggler-border-color: #{$navbar-dark-toggler-border-color};
+ --#{$prefix}navbar-toggler-icon-bg: #{escape-svg($navbar-dark-toggler-icon-bg)};
+ // scss-docs-end navbar-dark-css-vars
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_offcanvas.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_offcanvas.scss
new file mode 100644
index 0000000000..23fc357f2b
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_offcanvas.scss
@@ -0,0 +1,144 @@
+// stylelint-disable function-disallowed-list
+
+%offcanvas-css-vars {
+ // scss-docs-start offcanvas-css-vars
+ --#{$prefix}offcanvas-zindex: #{$zindex-offcanvas};
+ --#{$prefix}offcanvas-width: #{$offcanvas-horizontal-width};
+ --#{$prefix}offcanvas-height: #{$offcanvas-vertical-height};
+ --#{$prefix}offcanvas-padding-x: #{$offcanvas-padding-x};
+ --#{$prefix}offcanvas-padding-y: #{$offcanvas-padding-y};
+ --#{$prefix}offcanvas-color: #{$offcanvas-color};
+ --#{$prefix}offcanvas-bg: #{$offcanvas-bg-color};
+ --#{$prefix}offcanvas-border-width: #{$offcanvas-border-width};
+ --#{$prefix}offcanvas-border-color: #{$offcanvas-border-color};
+ --#{$prefix}offcanvas-box-shadow: #{$offcanvas-box-shadow};
+ // scss-docs-end offcanvas-css-vars
+}
+
+@each $breakpoint in map-keys($grid-breakpoints) {
+ $next: breakpoint-next($breakpoint, $grid-breakpoints);
+ $infix: breakpoint-infix($next, $grid-breakpoints);
+
+ .offcanvas#{$infix} {
+ @extend %offcanvas-css-vars;
+ }
+}
+
+@each $breakpoint in map-keys($grid-breakpoints) {
+ $next: breakpoint-next($breakpoint, $grid-breakpoints);
+ $infix: breakpoint-infix($next, $grid-breakpoints);
+
+ .offcanvas#{$infix} {
+ @include media-breakpoint-down($next) {
+ position: fixed;
+ bottom: 0;
+ z-index: var(--#{$prefix}offcanvas-zindex);
+ display: flex;
+ flex-direction: column;
+ max-width: 100%;
+ color: var(--#{$prefix}offcanvas-color);
+ visibility: hidden;
+ background-color: var(--#{$prefix}offcanvas-bg);
+ background-clip: padding-box;
+ outline: 0;
+ @include box-shadow(var(--#{$prefix}offcanvas-box-shadow));
+ @include transition(transform $offcanvas-transition-duration ease-in-out);
+
+ &.offcanvas-start {
+ top: 0;
+ left: 0;
+ width: var(--#{$prefix}offcanvas-width);
+ border-right: var(--#{$prefix}offcanvas-border-width) solid var(--#{$prefix}offcanvas-border-color);
+ transform: translateX(-100%);
+ }
+
+ &.offcanvas-end {
+ top: 0;
+ right: 0;
+ width: var(--#{$prefix}offcanvas-width);
+ border-left: var(--#{$prefix}offcanvas-border-width) solid var(--#{$prefix}offcanvas-border-color);
+ transform: translateX(100%);
+ }
+
+ &.offcanvas-top {
+ top: 0;
+ right: 0;
+ left: 0;
+ height: var(--#{$prefix}offcanvas-height);
+ max-height: 100%;
+ border-bottom: var(--#{$prefix}offcanvas-border-width) solid var(--#{$prefix}offcanvas-border-color);
+ transform: translateY(-100%);
+ }
+
+ &.offcanvas-bottom {
+ right: 0;
+ left: 0;
+ height: var(--#{$prefix}offcanvas-height);
+ max-height: 100%;
+ border-top: var(--#{$prefix}offcanvas-border-width) solid var(--#{$prefix}offcanvas-border-color);
+ transform: translateY(100%);
+ }
+
+ &.showing,
+ &.show:not(.hiding) {
+ transform: none;
+ }
+
+ &.showing,
+ &.hiding,
+ &.show {
+ visibility: visible;
+ }
+ }
+
+ @if not ($infix == "") {
+ @include media-breakpoint-up($next) {
+ --#{$prefix}offcanvas-height: auto;
+ --#{$prefix}offcanvas-border-width: 0;
+ background-color: transparent !important; // stylelint-disable-line declaration-no-important
+
+ .offcanvas-header {
+ display: none;
+ }
+
+ .offcanvas-body {
+ display: flex;
+ flex-grow: 0;
+ padding: 0;
+ overflow-y: visible;
+ // Reset `background-color` in case `.bg-*` classes are used in offcanvas
+ background-color: transparent !important; // stylelint-disable-line declaration-no-important
+ }
+ }
+ }
+ }
+}
+
+.offcanvas-backdrop {
+ @include overlay-backdrop($zindex-offcanvas-backdrop, $offcanvas-backdrop-bg, $offcanvas-backdrop-opacity);
+}
+
+.offcanvas-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--#{$prefix}offcanvas-padding-y) var(--#{$prefix}offcanvas-padding-x);
+
+ .btn-close {
+ padding: calc(var(--#{$prefix}offcanvas-padding-y) * .5) calc(var(--#{$prefix}offcanvas-padding-x) * .5);
+ margin-top: calc(-.5 * var(--#{$prefix}offcanvas-padding-y));
+ margin-right: calc(-.5 * var(--#{$prefix}offcanvas-padding-x));
+ margin-bottom: calc(-.5 * var(--#{$prefix}offcanvas-padding-y));
+ }
+}
+
+.offcanvas-title {
+ margin-bottom: 0;
+ line-height: $offcanvas-title-line-height;
+}
+
+.offcanvas-body {
+ flex-grow: 1;
+ padding: var(--#{$prefix}offcanvas-padding-y) var(--#{$prefix}offcanvas-padding-x);
+ overflow-y: auto;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_pagination.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_pagination.scss
new file mode 100644
index 0000000000..cf4db3c361
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_pagination.scss
@@ -0,0 +1,109 @@
+.pagination {
+ // scss-docs-start pagination-css-vars
+ --#{$prefix}pagination-padding-x: #{$pagination-padding-x};
+ --#{$prefix}pagination-padding-y: #{$pagination-padding-y};
+ @include rfs($pagination-font-size, --#{$prefix}pagination-font-size);
+ --#{$prefix}pagination-color: #{$pagination-color};
+ --#{$prefix}pagination-bg: #{$pagination-bg};
+ --#{$prefix}pagination-border-width: #{$pagination-border-width};
+ --#{$prefix}pagination-border-color: #{$pagination-border-color};
+ --#{$prefix}pagination-border-radius: #{$pagination-border-radius};
+ --#{$prefix}pagination-hover-color: #{$pagination-hover-color};
+ --#{$prefix}pagination-hover-bg: #{$pagination-hover-bg};
+ --#{$prefix}pagination-hover-border-color: #{$pagination-hover-border-color};
+ --#{$prefix}pagination-focus-color: #{$pagination-focus-color};
+ --#{$prefix}pagination-focus-bg: #{$pagination-focus-bg};
+ --#{$prefix}pagination-focus-box-shadow: #{$pagination-focus-box-shadow};
+ --#{$prefix}pagination-active-color: #{$pagination-active-color};
+ --#{$prefix}pagination-active-bg: #{$pagination-active-bg};
+ --#{$prefix}pagination-active-border-color: #{$pagination-active-border-color};
+ --#{$prefix}pagination-disabled-color: #{$pagination-disabled-color};
+ --#{$prefix}pagination-disabled-bg: #{$pagination-disabled-bg};
+ --#{$prefix}pagination-disabled-border-color: #{$pagination-disabled-border-color};
+ // scss-docs-end pagination-css-vars
+
+ display: flex;
+ @include list-unstyled();
+}
+
+.page-link {
+ position: relative;
+ display: block;
+ padding: var(--#{$prefix}pagination-padding-y) var(--#{$prefix}pagination-padding-x);
+ @include font-size(var(--#{$prefix}pagination-font-size));
+ color: var(--#{$prefix}pagination-color);
+ text-decoration: if($link-decoration == none, null, none);
+ background-color: var(--#{$prefix}pagination-bg);
+ border: var(--#{$prefix}pagination-border-width) solid var(--#{$prefix}pagination-border-color);
+ @include transition($pagination-transition);
+
+ &:hover {
+ z-index: 2;
+ color: var(--#{$prefix}pagination-hover-color);
+ text-decoration: if($link-hover-decoration == underline, none, null);
+ background-color: var(--#{$prefix}pagination-hover-bg);
+ border-color: var(--#{$prefix}pagination-hover-border-color);
+ }
+
+ &:focus {
+ z-index: 3;
+ color: var(--#{$prefix}pagination-focus-color);
+ background-color: var(--#{$prefix}pagination-focus-bg);
+ outline: $pagination-focus-outline;
+ box-shadow: var(--#{$prefix}pagination-focus-box-shadow);
+ }
+
+ &.active,
+ .active > & {
+ z-index: 3;
+ color: var(--#{$prefix}pagination-active-color);
+ @include gradient-bg(var(--#{$prefix}pagination-active-bg));
+ border-color: var(--#{$prefix}pagination-active-border-color);
+ }
+
+ &.disabled,
+ .disabled > & {
+ color: var(--#{$prefix}pagination-disabled-color);
+ pointer-events: none;
+ background-color: var(--#{$prefix}pagination-disabled-bg);
+ border-color: var(--#{$prefix}pagination-disabled-border-color);
+ }
+}
+
+.page-item {
+ &:not(:first-child) .page-link {
+ margin-left: $pagination-margin-start;
+ }
+
+ @if $pagination-margin-start == ($pagination-border-width * -1) {
+ &:first-child {
+ .page-link {
+ @include border-start-radius(var(--#{$prefix}pagination-border-radius));
+ }
+ }
+
+ &:last-child {
+ .page-link {
+ @include border-end-radius(var(--#{$prefix}pagination-border-radius));
+ }
+ }
+ } @else {
+ // Add border-radius to all pageLinks in case they have left margin
+ .page-link {
+ @include border-radius(var(--#{$prefix}pagination-border-radius));
+ }
+ }
+}
+
+
+//
+// Sizing
+//
+
+.pagination-lg {
+ @include pagination-size($pagination-padding-y-lg, $pagination-padding-x-lg, $font-size-lg, $pagination-border-radius-lg);
+}
+
+.pagination-sm {
+ @include pagination-size($pagination-padding-y-sm, $pagination-padding-x-sm, $font-size-sm, $pagination-border-radius-sm);
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_placeholders.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_placeholders.scss
new file mode 100644
index 0000000000..6e32e1cdb9
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_placeholders.scss
@@ -0,0 +1,51 @@
+.placeholder {
+ display: inline-block;
+ min-height: 1em;
+ vertical-align: middle;
+ cursor: wait;
+ background-color: currentcolor;
+ opacity: $placeholder-opacity-max;
+
+ &.btn::before {
+ display: inline-block;
+ content: "";
+ }
+}
+
+// Sizing
+.placeholder-xs {
+ min-height: .6em;
+}
+
+.placeholder-sm {
+ min-height: .8em;
+}
+
+.placeholder-lg {
+ min-height: 1.2em;
+}
+
+// Animation
+.placeholder-glow {
+ .placeholder {
+ animation: placeholder-glow 2s ease-in-out infinite;
+ }
+}
+
+@keyframes placeholder-glow {
+ 50% {
+ opacity: $placeholder-opacity-min;
+ }
+}
+
+.placeholder-wave {
+ mask-image: linear-gradient(130deg, $black 55%, rgba(0, 0, 0, (1 - $placeholder-opacity-min)) 75%, $black 95%);
+ mask-size: 200% 100%;
+ animation: placeholder-wave 2s linear infinite;
+}
+
+@keyframes placeholder-wave {
+ 100% {
+ mask-position: -200% 0%;
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_popover.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_popover.scss
new file mode 100644
index 0000000000..7b69f62328
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_popover.scss
@@ -0,0 +1,196 @@
+.popover {
+ // scss-docs-start popover-css-vars
+ --#{$prefix}popover-zindex: #{$zindex-popover};
+ --#{$prefix}popover-max-width: #{$popover-max-width};
+ @include rfs($popover-font-size, --#{$prefix}popover-font-size);
+ --#{$prefix}popover-bg: #{$popover-bg};
+ --#{$prefix}popover-border-width: #{$popover-border-width};
+ --#{$prefix}popover-border-color: #{$popover-border-color};
+ --#{$prefix}popover-border-radius: #{$popover-border-radius};
+ --#{$prefix}popover-inner-border-radius: #{$popover-inner-border-radius};
+ --#{$prefix}popover-box-shadow: #{$popover-box-shadow};
+ --#{$prefix}popover-header-padding-x: #{$popover-header-padding-x};
+ --#{$prefix}popover-header-padding-y: #{$popover-header-padding-y};
+ @include rfs($popover-header-font-size, --#{$prefix}popover-header-font-size);
+ --#{$prefix}popover-header-color: #{$popover-header-color};
+ --#{$prefix}popover-header-bg: #{$popover-header-bg};
+ --#{$prefix}popover-body-padding-x: #{$popover-body-padding-x};
+ --#{$prefix}popover-body-padding-y: #{$popover-body-padding-y};
+ --#{$prefix}popover-body-color: #{$popover-body-color};
+ --#{$prefix}popover-arrow-width: #{$popover-arrow-width};
+ --#{$prefix}popover-arrow-height: #{$popover-arrow-height};
+ --#{$prefix}popover-arrow-border: var(--#{$prefix}popover-border-color);
+ // scss-docs-end popover-css-vars
+
+ z-index: var(--#{$prefix}popover-zindex);
+ display: block;
+ max-width: var(--#{$prefix}popover-max-width);
+ // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.
+ // So reset our font and text properties to avoid inheriting weird values.
+ @include reset-text();
+ @include font-size(var(--#{$prefix}popover-font-size));
+ // Allow breaking very long words so they don't overflow the popover's bounds
+ word-wrap: break-word;
+ background-color: var(--#{$prefix}popover-bg);
+ background-clip: padding-box;
+ border: var(--#{$prefix}popover-border-width) solid var(--#{$prefix}popover-border-color);
+ @include border-radius(var(--#{$prefix}popover-border-radius));
+ @include box-shadow(var(--#{$prefix}popover-box-shadow));
+
+ .popover-arrow {
+ display: block;
+ width: var(--#{$prefix}popover-arrow-width);
+ height: var(--#{$prefix}popover-arrow-height);
+
+ &::before,
+ &::after {
+ position: absolute;
+ display: block;
+ content: "";
+ border-color: transparent;
+ border-style: solid;
+ border-width: 0;
+ }
+ }
+}
+
+.bs-popover-top {
+ > .popover-arrow {
+ bottom: calc(-1 * (var(--#{$prefix}popover-arrow-height)) - var(--#{$prefix}popover-border-width)); // stylelint-disable-line function-disallowed-list
+
+ &::before,
+ &::after {
+ border-width: var(--#{$prefix}popover-arrow-height) calc(var(--#{$prefix}popover-arrow-width) * .5) 0; // stylelint-disable-line function-disallowed-list
+ }
+
+ &::before {
+ bottom: 0;
+ border-top-color: var(--#{$prefix}popover-arrow-border);
+ }
+
+ &::after {
+ bottom: var(--#{$prefix}popover-border-width);
+ border-top-color: var(--#{$prefix}popover-bg);
+ }
+ }
+}
+
+/* rtl:begin:ignore */
+.bs-popover-end {
+ > .popover-arrow {
+ left: calc(-1 * (var(--#{$prefix}popover-arrow-height)) - var(--#{$prefix}popover-border-width)); // stylelint-disable-line function-disallowed-list
+ width: var(--#{$prefix}popover-arrow-height);
+ height: var(--#{$prefix}popover-arrow-width);
+
+ &::before,
+ &::after {
+ border-width: calc(var(--#{$prefix}popover-arrow-width) * .5) var(--#{$prefix}popover-arrow-height) calc(var(--#{$prefix}popover-arrow-width) * .5) 0; // stylelint-disable-line function-disallowed-list
+ }
+
+ &::before {
+ left: 0;
+ border-right-color: var(--#{$prefix}popover-arrow-border);
+ }
+
+ &::after {
+ left: var(--#{$prefix}popover-border-width);
+ border-right-color: var(--#{$prefix}popover-bg);
+ }
+ }
+}
+
+/* rtl:end:ignore */
+
+.bs-popover-bottom {
+ > .popover-arrow {
+ top: calc(-1 * (var(--#{$prefix}popover-arrow-height)) - var(--#{$prefix}popover-border-width)); // stylelint-disable-line function-disallowed-list
+
+ &::before,
+ &::after {
+ border-width: 0 calc(var(--#{$prefix}popover-arrow-width) * .5) var(--#{$prefix}popover-arrow-height); // stylelint-disable-line function-disallowed-list
+ }
+
+ &::before {
+ top: 0;
+ border-bottom-color: var(--#{$prefix}popover-arrow-border);
+ }
+
+ &::after {
+ top: var(--#{$prefix}popover-border-width);
+ border-bottom-color: var(--#{$prefix}popover-bg);
+ }
+ }
+
+ // This will remove the popover-header's border just below the arrow
+ .popover-header::before {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ display: block;
+ width: var(--#{$prefix}popover-arrow-width);
+ margin-left: calc(-.5 * var(--#{$prefix}popover-arrow-width)); // stylelint-disable-line function-disallowed-list
+ content: "";
+ border-bottom: var(--#{$prefix}popover-border-width) solid var(--#{$prefix}popover-header-bg);
+ }
+}
+
+/* rtl:begin:ignore */
+.bs-popover-start {
+ > .popover-arrow {
+ right: calc(-1 * (var(--#{$prefix}popover-arrow-height)) - var(--#{$prefix}popover-border-width)); // stylelint-disable-line function-disallowed-list
+ width: var(--#{$prefix}popover-arrow-height);
+ height: var(--#{$prefix}popover-arrow-width);
+
+ &::before,
+ &::after {
+ border-width: calc(var(--#{$prefix}popover-arrow-width) * .5) 0 calc(var(--#{$prefix}popover-arrow-width) * .5) var(--#{$prefix}popover-arrow-height); // stylelint-disable-line function-disallowed-list
+ }
+
+ &::before {
+ right: 0;
+ border-left-color: var(--#{$prefix}popover-arrow-border);
+ }
+
+ &::after {
+ right: var(--#{$prefix}popover-border-width);
+ border-left-color: var(--#{$prefix}popover-bg);
+ }
+ }
+}
+
+/* rtl:end:ignore */
+
+.bs-popover-auto {
+ &[data-popper-placement^="top"] {
+ @extend .bs-popover-top;
+ }
+ &[data-popper-placement^="right"] {
+ @extend .bs-popover-end;
+ }
+ &[data-popper-placement^="bottom"] {
+ @extend .bs-popover-bottom;
+ }
+ &[data-popper-placement^="left"] {
+ @extend .bs-popover-start;
+ }
+}
+
+// Offset the popover to account for the popover arrow
+.popover-header {
+ padding: var(--#{$prefix}popover-header-padding-y) var(--#{$prefix}popover-header-padding-x);
+ margin-bottom: 0; // Reset the default from Reboot
+ @include font-size(var(--#{$prefix}popover-header-font-size));
+ color: var(--#{$prefix}popover-header-color);
+ background-color: var(--#{$prefix}popover-header-bg);
+ border-bottom: var(--#{$prefix}popover-border-width) solid var(--#{$prefix}popover-border-color);
+ @include border-top-radius(var(--#{$prefix}popover-inner-border-radius));
+
+ &:empty {
+ display: none;
+ }
+}
+
+.popover-body {
+ padding: var(--#{$prefix}popover-body-padding-y) var(--#{$prefix}popover-body-padding-x);
+ color: var(--#{$prefix}popover-body-color);
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_progress.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_progress.scss
new file mode 100644
index 0000000000..1bfafb58fa
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_progress.scss
@@ -0,0 +1,59 @@
+// Disable animation if transitions are disabled
+
+// scss-docs-start progress-keyframes
+@if $enable-transitions {
+ @keyframes progress-bar-stripes {
+ 0% { background-position-x: $progress-height; }
+ }
+}
+// scss-docs-end progress-keyframes
+
+.progress {
+ // scss-docs-start progress-css-vars
+ --#{$prefix}progress-height: #{$progress-height};
+ @include rfs($progress-font-size, --#{$prefix}progress-font-size);
+ --#{$prefix}progress-bg: #{$progress-bg};
+ --#{$prefix}progress-border-radius: #{$progress-border-radius};
+ --#{$prefix}progress-box-shadow: #{$progress-box-shadow};
+ --#{$prefix}progress-bar-color: #{$progress-bar-color};
+ --#{$prefix}progress-bar-bg: #{$progress-bar-bg};
+ --#{$prefix}progress-bar-transition: #{$progress-bar-transition};
+ // scss-docs-end progress-css-vars
+
+ display: flex;
+ height: var(--#{$prefix}progress-height);
+ overflow: hidden; // force rounded corners by cropping it
+ @include font-size(var(--#{$prefix}progress-font-size));
+ background-color: var(--#{$prefix}progress-bg);
+ @include border-radius(var(--#{$prefix}progress-border-radius));
+ @include box-shadow(var(--#{$prefix}progress-box-shadow));
+}
+
+.progress-bar {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ overflow: hidden;
+ color: var(--#{$prefix}progress-bar-color);
+ text-align: center;
+ white-space: nowrap;
+ background-color: var(--#{$prefix}progress-bar-bg);
+ @include transition(var(--#{$prefix}progress-bar-transition));
+}
+
+.progress-bar-striped {
+ @include gradient-striped();
+ background-size: var(--#{$prefix}progress-height) var(--#{$prefix}progress-height);
+}
+
+@if $enable-transitions {
+ .progress-bar-animated {
+ animation: $progress-bar-animation-timing progress-bar-stripes;
+
+ @if $enable-reduced-motion {
+ @media (prefers-reduced-motion: reduce) {
+ animation: none;
+ }
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_reboot.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_reboot.scss
new file mode 100644
index 0000000000..8ac790399f
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_reboot.scss
@@ -0,0 +1,610 @@
+// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix
+
+
+// Reboot
+//
+// Normalization of HTML elements, manually forked from Normalize.css to remove
+// styles targeting irrelevant browsers while applying new styles.
+//
+// Normalize is licensed MIT. https://github.com/necolas/normalize.css
+
+
+// Document
+//
+// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+
+// Root
+//
+// Ability to the value of the root font sizes, affecting the value of `rem`.
+// null by default, thus nothing is generated.
+
+:root {
+ @if $font-size-root != null {
+ @include font-size(var(--#{$prefix}root-font-size));
+ }
+
+ @if $enable-smooth-scroll {
+ @media (prefers-reduced-motion: no-preference) {
+ scroll-behavior: smooth;
+ }
+ }
+}
+
+
+// Body
+//
+// 1. Remove the margin in all browsers.
+// 2. As a best practice, apply a default `background-color`.
+// 3. Prevent adjustments of font size after orientation changes in iOS.
+// 4. Change the default tap highlight to be completely transparent in iOS.
+
+// scss-docs-start reboot-body-rules
+body {
+ margin: 0; // 1
+ font-family: var(--#{$prefix}body-font-family);
+ @include font-size(var(--#{$prefix}body-font-size));
+ font-weight: var(--#{$prefix}body-font-weight);
+ line-height: var(--#{$prefix}body-line-height);
+ color: var(--#{$prefix}body-color);
+ text-align: var(--#{$prefix}body-text-align);
+ background-color: var(--#{$prefix}body-bg); // 2
+ -webkit-text-size-adjust: 100%; // 3
+ -webkit-tap-highlight-color: rgba($black, 0); // 4
+}
+// scss-docs-end reboot-body-rules
+
+
+// Content grouping
+//
+// 1. Reset Firefox's gray color
+
+hr {
+ margin: $hr-margin-y 0;
+ color: $hr-color; // 1
+ border: 0;
+ border-top: $hr-border-width solid $hr-border-color;
+ opacity: $hr-opacity;
+}
+
+
+// Typography
+//
+// 1. Remove top margins from headings
+// By default, `<h1>`-`<h6>` all receive top and bottom margins. We nuke the top
+// margin for easier control within type scales as it avoids margin collapsing.
+
+%heading {
+ margin-top: 0; // 1
+ margin-bottom: $headings-margin-bottom;
+ font-family: $headings-font-family;
+ font-style: $headings-font-style;
+ font-weight: $headings-font-weight;
+ line-height: $headings-line-height;
+ color: $headings-color;
+}
+
+h1 {
+ @extend %heading;
+ @include font-size($h1-font-size);
+}
+
+h2 {
+ @extend %heading;
+ @include font-size($h2-font-size);
+}
+
+h3 {
+ @extend %heading;
+ @include font-size($h3-font-size);
+}
+
+h4 {
+ @extend %heading;
+ @include font-size($h4-font-size);
+}
+
+h5 {
+ @extend %heading;
+ @include font-size($h5-font-size);
+}
+
+h6 {
+ @extend %heading;
+ @include font-size($h6-font-size);
+}
+
+
+// Reset margins on paragraphs
+//
+// Similarly, the top margin on `<p>`s get reset. However, we also reset the
+// bottom margin to use `rem` units instead of `em`.
+
+p {
+ margin-top: 0;
+ margin-bottom: $paragraph-margin-bottom;
+}
+
+
+// Abbreviations
+//
+// 1. Add the correct text decoration in Chrome, Edge, Opera, and Safari.
+// 2. Add explicit cursor to indicate changed behavior.
+// 3. Prevent the text-decoration to be skipped.
+
+abbr[title] {
+ text-decoration: underline dotted; // 1
+ cursor: help; // 2
+ text-decoration-skip-ink: none; // 3
+}
+
+
+// Address
+
+address {
+ margin-bottom: 1rem;
+ font-style: normal;
+ line-height: inherit;
+}
+
+
+// Lists
+
+ol,
+ul {
+ padding-left: 2rem;
+}
+
+ol,
+ul,
+dl {
+ margin-top: 0;
+ margin-bottom: 1rem;
+}
+
+ol ol,
+ul ul,
+ol ul,
+ul ol {
+ margin-bottom: 0;
+}
+
+dt {
+ font-weight: $dt-font-weight;
+}
+
+// 1. Undo browser default
+
+dd {
+ margin-bottom: .5rem;
+ margin-left: 0; // 1
+}
+
+
+// Blockquote
+
+blockquote {
+ margin: 0 0 1rem;
+}
+
+
+// Strong
+//
+// Add the correct font weight in Chrome, Edge, and Safari
+
+b,
+strong {
+ font-weight: $font-weight-bolder;
+}
+
+
+// Small
+//
+// Add the correct font size in all browsers
+
+small {
+ @include font-size($small-font-size);
+}
+
+
+// Mark
+
+mark {
+ padding: $mark-padding;
+ background-color: var(--#{$prefix}highlight-bg);
+}
+
+
+// Sub and Sup
+//
+// Prevent `sub` and `sup` elements from affecting the line height in
+// all browsers.
+
+sub,
+sup {
+ position: relative;
+ @include font-size($sub-sup-font-size);
+ line-height: 0;
+ vertical-align: baseline;
+}
+
+sub { bottom: -.25em; }
+sup { top: -.5em; }
+
+
+// Links
+
+a {
+ color: var(--#{$prefix}link-color);
+ text-decoration: $link-decoration;
+
+ &:hover {
+ color: var(--#{$prefix}link-hover-color);
+ text-decoration: $link-hover-decoration;
+ }
+}
+
+// And undo these styles for placeholder links/named anchors (without href).
+// It would be more straightforward to just use a[href] in previous block, but that
+// causes specificity issues in many other styles that are too complex to fix.
+// See https://github.com/twbs/bootstrap/issues/19402
+
+a:not([href]):not([class]) {
+ &,
+ &:hover {
+ color: inherit;
+ text-decoration: none;
+ }
+}
+
+
+// Code
+
+pre,
+code,
+kbd,
+samp {
+ font-family: $font-family-code;
+ @include font-size(1em); // Correct the odd `em` font sizing in all browsers.
+}
+
+// 1. Remove browser default top margin
+// 2. Reset browser default of `1em` to use `rem`s
+// 3. Don't allow content to break outside
+
+pre {
+ display: block;
+ margin-top: 0; // 1
+ margin-bottom: 1rem; // 2
+ overflow: auto; // 3
+ @include font-size($code-font-size);
+ color: $pre-color;
+
+ // Account for some code outputs that place code tags in pre tags
+ code {
+ @include font-size(inherit);
+ color: inherit;
+ word-break: normal;
+ }
+}
+
+code {
+ @include font-size($code-font-size);
+ color: var(--#{$prefix}code-color);
+ word-wrap: break-word;
+
+ // Streamline the style when inside anchors to avoid broken underline and more
+ a > & {
+ color: inherit;
+ }
+}
+
+kbd {
+ padding: $kbd-padding-y $kbd-padding-x;
+ @include font-size($kbd-font-size);
+ color: $kbd-color;
+ background-color: $kbd-bg;
+ @include border-radius($border-radius-sm);
+
+ kbd {
+ padding: 0;
+ @include font-size(1em);
+ font-weight: $nested-kbd-font-weight;
+ }
+}
+
+
+// Figures
+//
+// Apply a consistent margin strategy (matches our type styles).
+
+figure {
+ margin: 0 0 1rem;
+}
+
+
+// Images and content
+
+img,
+svg {
+ vertical-align: middle;
+}
+
+
+// Tables
+//
+// Prevent double borders
+
+table {
+ caption-side: bottom;
+ border-collapse: collapse;
+}
+
+caption {
+ padding-top: $table-cell-padding-y;
+ padding-bottom: $table-cell-padding-y;
+ color: $table-caption-color;
+ text-align: left;
+}
+
+// 1. Removes font-weight bold by inheriting
+// 2. Matches default `<td>` alignment by inheriting `text-align`.
+// 3. Fix alignment for Safari
+
+th {
+ font-weight: $table-th-font-weight; // 1
+ text-align: inherit; // 2
+ text-align: -webkit-match-parent; // 3
+}
+
+thead,
+tbody,
+tfoot,
+tr,
+td,
+th {
+ border-color: inherit;
+ border-style: solid;
+ border-width: 0;
+}
+
+
+// Forms
+//
+// 1. Allow labels to use `margin` for spacing.
+
+label {
+ display: inline-block; // 1
+}
+
+// Remove the default `border-radius` that macOS Chrome adds.
+// See https://github.com/twbs/bootstrap/issues/24093
+
+button {
+ // stylelint-disable-next-line property-disallowed-list
+ border-radius: 0;
+}
+
+// Explicitly remove focus outline in Chromium when it shouldn't be
+// visible (e.g. as result of mouse click or touch tap). It already
+// should be doing this automatically, but seems to currently be
+// confused and applies its very visible two-tone outline anyway.
+
+button:focus:not(:focus-visible) {
+ outline: 0;
+}
+
+// 1. Remove the margin in Firefox and Safari
+
+input,
+button,
+select,
+optgroup,
+textarea {
+ margin: 0; // 1
+ font-family: inherit;
+ @include font-size(inherit);
+ line-height: inherit;
+}
+
+// Remove the inheritance of text transform in Firefox
+button,
+select {
+ text-transform: none;
+}
+// Set the cursor for non-`<button>` buttons
+//
+// Details at https://github.com/twbs/bootstrap/pull/30562
+[role="button"] {
+ cursor: pointer;
+}
+
+select {
+ // Remove the inheritance of word-wrap in Safari.
+ // See https://github.com/twbs/bootstrap/issues/24990
+ word-wrap: normal;
+
+ // Undo the opacity change from Chrome
+ &:disabled {
+ opacity: 1;
+ }
+}
+
+// Remove the dropdown arrow only from text type inputs built with datalists in Chrome.
+// See https://stackoverflow.com/a/54997118
+
+[list]:not([type="date"]):not([type="datetime-local"]):not([type="month"]):not([type="week"]):not([type="time"])::-webkit-calendar-picker-indicator {
+ display: none !important;
+}
+
+// 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
+// controls in Android 4.
+// 2. Correct the inability to style clickable types in iOS and Safari.
+// 3. Opinionated: add "hand" cursor to non-disabled button elements.
+
+button,
+[type="button"], // 1
+[type="reset"],
+[type="submit"] {
+ -webkit-appearance: button; // 2
+
+ @if $enable-button-pointers {
+ &:not(:disabled) {
+ cursor: pointer; // 3
+ }
+ }
+}
+
+// Remove inner border and padding from Firefox, but don't restore the outline like Normalize.
+
+::-moz-focus-inner {
+ padding: 0;
+ border-style: none;
+}
+
+// 1. Textareas should really only resize vertically so they don't break their (horizontal) containers.
+
+textarea {
+ resize: vertical; // 1
+}
+
+// 1. Browsers set a default `min-width: min-content;` on fieldsets,
+// unlike e.g. `<div>`s, which have `min-width: 0;` by default.
+// So we reset that to ensure fieldsets behave more like a standard block element.
+// See https://github.com/twbs/bootstrap/issues/12359
+// and https://html.spec.whatwg.org/multipage/#the-fieldset-and-legend-elements
+// 2. Reset the default outline behavior of fieldsets so they don't affect page layout.
+
+fieldset {
+ min-width: 0; // 1
+ padding: 0; // 2
+ margin: 0; // 2
+ border: 0; // 2
+}
+
+// 1. By using `float: left`, the legend will behave like a block element.
+// This way the border of a fieldset wraps around the legend if present.
+// 2. Fix wrapping bug.
+// See https://github.com/twbs/bootstrap/issues/29712
+
+legend {
+ float: left; // 1
+ width: 100%;
+ padding: 0;
+ margin-bottom: $legend-margin-bottom;
+ @include font-size($legend-font-size);
+ font-weight: $legend-font-weight;
+ line-height: inherit;
+
+ + * {
+ clear: left; // 2
+ }
+}
+
+// Fix height of inputs with a type of datetime-local, date, month, week, or time
+// See https://github.com/twbs/bootstrap/issues/18842
+
+::-webkit-datetime-edit-fields-wrapper,
+::-webkit-datetime-edit-text,
+::-webkit-datetime-edit-minute,
+::-webkit-datetime-edit-hour-field,
+::-webkit-datetime-edit-day-field,
+::-webkit-datetime-edit-month-field,
+::-webkit-datetime-edit-year-field {
+ padding: 0;
+}
+
+::-webkit-inner-spin-button {
+ height: auto;
+}
+
+// 1. Correct the outline style in Safari.
+// 2. This overrides the extra rounded corners on search inputs in iOS so that our
+// `.form-control` class can properly style them. Note that this cannot simply
+// be added to `.form-control` as it's not specific enough. For details, see
+// https://github.com/twbs/bootstrap/issues/11586.
+
+[type="search"] {
+ outline-offset: -2px; // 1
+ -webkit-appearance: textfield; // 2
+}
+
+// 1. A few input types should stay LTR
+// See https://rtlstyling.com/posts/rtl-styling#form-inputs
+// 2. RTL only output
+// See https://rtlcss.com/learn/usage-guide/control-directives/#raw
+
+/* rtl:raw:
+[type="tel"],
+[type="url"],
+[type="email"],
+[type="number"] {
+ direction: ltr;
+}
+*/
+
+// Remove the inner padding in Chrome and Safari on macOS.
+
+::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+// Remove padding around color pickers in webkit browsers
+
+::-webkit-color-swatch-wrapper {
+ padding: 0;
+}
+
+
+// 1. Inherit font family and line height for file input buttons
+// 2. Correct the inability to style clickable types in iOS and Safari.
+
+::file-selector-button {
+ font: inherit; // 1
+ -webkit-appearance: button; // 2
+}
+
+// Correct element displays
+
+output {
+ display: inline-block;
+}
+
+// Remove border from iframe
+
+iframe {
+ border: 0;
+}
+
+// Summary
+//
+// 1. Add the correct display in all browsers
+
+summary {
+ display: list-item; // 1
+ cursor: pointer;
+}
+
+
+// Progress
+//
+// Add the correct vertical alignment in Chrome, Firefox, and Opera.
+
+progress {
+ vertical-align: baseline;
+}
+
+
+// Hidden attribute
+//
+// Always hide an element with the `hidden` HTML attribute.
+
+[hidden] {
+ display: none !important;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_root.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_root.scss
new file mode 100644
index 0000000000..e64ae04e57
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_root.scss
@@ -0,0 +1,73 @@
+:root {
+ // Note: Custom variable values only support SassScript inside `#{}`.
+
+ // Colors
+ //
+ // Generate palettes for full colors, grays, and theme colors.
+
+ @each $color, $value in $colors {
+ --#{$prefix}#{$color}: #{$value};
+ }
+
+ @each $color, $value in $grays {
+ --#{$prefix}gray-#{$color}: #{$value};
+ }
+
+ @each $color, $value in $theme-colors {
+ --#{$prefix}#{$color}: #{$value};
+ }
+
+ @each $color, $value in $theme-colors-rgb {
+ --#{$prefix}#{$color}-rgb: #{$value};
+ }
+
+ --#{$prefix}white-rgb: #{to-rgb($white)};
+ --#{$prefix}black-rgb: #{to-rgb($black)};
+ --#{$prefix}body-color-rgb: #{to-rgb($body-color)};
+ --#{$prefix}body-bg-rgb: #{to-rgb($body-bg)};
+
+ // Fonts
+
+ // Note: Use `inspect` for lists so that quoted items keep the quotes.
+ // See https://github.com/sass/sass/issues/2383#issuecomment-336349172
+ --#{$prefix}font-sans-serif: #{inspect($font-family-sans-serif)};
+ --#{$prefix}font-monospace: #{inspect($font-family-monospace)};
+ --#{$prefix}gradient: #{$gradient};
+
+ // Root and body
+ // scss-docs-start root-body-variables
+ @if $font-size-root != null {
+ --#{$prefix}root-font-size: #{$font-size-root};
+ }
+ --#{$prefix}body-font-family: #{$font-family-base};
+ @include rfs($font-size-base, --#{$prefix}body-font-size);
+ --#{$prefix}body-font-weight: #{$font-weight-base};
+ --#{$prefix}body-line-height: #{$line-height-base};
+ --#{$prefix}body-color: #{$body-color};
+ @if $body-text-align != null {
+ --#{$prefix}body-text-align: #{$body-text-align};
+ }
+ --#{$prefix}body-bg: #{$body-bg};
+ // scss-docs-end root-body-variables
+
+ // scss-docs-start root-border-var
+ --#{$prefix}border-width: #{$border-width};
+ --#{$prefix}border-style: #{$border-style};
+ --#{$prefix}border-color: #{$border-color};
+ --#{$prefix}border-color-translucent: #{$border-color-translucent};
+
+ --#{$prefix}border-radius: #{$border-radius};
+ --#{$prefix}border-radius-sm: #{$border-radius-sm};
+ --#{$prefix}border-radius-lg: #{$border-radius-lg};
+ --#{$prefix}border-radius-xl: #{$border-radius-xl};
+ --#{$prefix}border-radius-2xl: #{$border-radius-2xl};
+ --#{$prefix}border-radius-pill: #{$border-radius-pill};
+ // scss-docs-end root-border-var
+
+ --#{$prefix}link-color: #{$link-color};
+ --#{$prefix}link-hover-color: #{$link-hover-color};
+
+ --#{$prefix}code-color: #{$code-color};
+
+ --#{$prefix}highlight-bg: #{$mark-bg};
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_spinners.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_spinners.scss
new file mode 100644
index 0000000000..ec8473207e
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_spinners.scss
@@ -0,0 +1,85 @@
+//
+// Rotating border
+//
+
+.spinner-grow,
+.spinner-border {
+ display: inline-block;
+ width: var(--#{$prefix}spinner-width);
+ height: var(--#{$prefix}spinner-height);
+ vertical-align: var(--#{$prefix}spinner-vertical-align);
+ // stylelint-disable-next-line property-disallowed-list
+ border-radius: 50%;
+ animation: var(--#{$prefix}spinner-animation-speed) linear infinite var(--#{$prefix}spinner-animation-name);
+}
+
+// scss-docs-start spinner-border-keyframes
+@keyframes spinner-border {
+ to { transform: rotate(360deg) #{"/* rtl:ignore */"}; }
+}
+// scss-docs-end spinner-border-keyframes
+
+.spinner-border {
+ // scss-docs-start spinner-border-css-vars
+ --#{$prefix}spinner-width: #{$spinner-width};
+ --#{$prefix}spinner-height: #{$spinner-height};
+ --#{$prefix}spinner-vertical-align: #{$spinner-vertical-align};
+ --#{$prefix}spinner-border-width: #{$spinner-border-width};
+ --#{$prefix}spinner-animation-speed: #{$spinner-animation-speed};
+ --#{$prefix}spinner-animation-name: spinner-border;
+ // scss-docs-end spinner-border-css-vars
+
+ border: var(--#{$prefix}spinner-border-width) solid currentcolor;
+ border-right-color: transparent;
+}
+
+.spinner-border-sm {
+ // scss-docs-start spinner-border-sm-css-vars
+ --#{$prefix}spinner-width: #{$spinner-width-sm};
+ --#{$prefix}spinner-height: #{$spinner-height-sm};
+ --#{$prefix}spinner-border-width: #{$spinner-border-width-sm};
+ // scss-docs-end spinner-border-sm-css-vars
+}
+
+//
+// Growing circle
+//
+
+// scss-docs-start spinner-grow-keyframes
+@keyframes spinner-grow {
+ 0% {
+ transform: scale(0);
+ }
+ 50% {
+ opacity: 1;
+ transform: none;
+ }
+}
+// scss-docs-end spinner-grow-keyframes
+
+.spinner-grow {
+ // scss-docs-start spinner-grow-css-vars
+ --#{$prefix}spinner-width: #{$spinner-width};
+ --#{$prefix}spinner-height: #{$spinner-height};
+ --#{$prefix}spinner-vertical-align: #{$spinner-vertical-align};
+ --#{$prefix}spinner-animation-speed: #{$spinner-animation-speed};
+ --#{$prefix}spinner-animation-name: spinner-grow;
+ // scss-docs-end spinner-grow-css-vars
+
+ background-color: currentcolor;
+ opacity: 0;
+}
+
+.spinner-grow-sm {
+ --#{$prefix}spinner-width: #{$spinner-width-sm};
+ --#{$prefix}spinner-height: #{$spinner-height-sm};
+}
+
+@if $enable-reduced-motion {
+ @media (prefers-reduced-motion: reduce) {
+ .spinner-border,
+ .spinner-grow {
+ --#{$prefix}spinner-animation-speed: #{$spinner-animation-speed * 2};
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_tables.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_tables.scss
new file mode 100644
index 0000000000..1fdd43c6bb
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_tables.scss
@@ -0,0 +1,164 @@
+//
+// Basic Bootstrap table
+//
+
+.table {
+ --#{$prefix}table-color: #{$table-color};
+ --#{$prefix}table-bg: #{$table-bg};
+ --#{$prefix}table-border-color: #{$table-border-color};
+ --#{$prefix}table-accent-bg: #{$table-accent-bg};
+ --#{$prefix}table-striped-color: #{$table-striped-color};
+ --#{$prefix}table-striped-bg: #{$table-striped-bg};
+ --#{$prefix}table-active-color: #{$table-active-color};
+ --#{$prefix}table-active-bg: #{$table-active-bg};
+ --#{$prefix}table-hover-color: #{$table-hover-color};
+ --#{$prefix}table-hover-bg: #{$table-hover-bg};
+
+ width: 100%;
+ margin-bottom: $spacer;
+ color: var(--#{$prefix}table-color);
+ vertical-align: $table-cell-vertical-align;
+ border-color: var(--#{$prefix}table-border-color);
+
+ // Target th & td
+ // We need the child combinator to prevent styles leaking to nested tables which doesn't have a `.table` class.
+ // We use the universal selectors here to simplify the selector (else we would need 6 different selectors).
+ // Another advantage is that this generates less code and makes the selector less specific making it easier to override.
+ // stylelint-disable-next-line selector-max-universal
+ > :not(caption) > * > * {
+ padding: $table-cell-padding-y $table-cell-padding-x;
+ background-color: var(--#{$prefix}table-bg);
+ border-bottom-width: $table-border-width;
+ box-shadow: inset 0 0 0 9999px var(--#{$prefix}table-accent-bg);
+ }
+
+ > tbody {
+ vertical-align: inherit;
+ }
+
+ > thead {
+ vertical-align: bottom;
+ }
+}
+
+.table-group-divider {
+ border-top: ($table-border-width * 2) solid $table-group-separator-color;
+}
+
+//
+// Change placement of captions with a class
+//
+
+.caption-top {
+ caption-side: top;
+}
+
+
+//
+// Condensed table w/ half padding
+//
+
+.table-sm {
+ // stylelint-disable-next-line selector-max-universal
+ > :not(caption) > * > * {
+ padding: $table-cell-padding-y-sm $table-cell-padding-x-sm;
+ }
+}
+
+
+// Border versions
+//
+// Add or remove borders all around the table and between all the columns.
+//
+// When borders are added on all sides of the cells, the corners can render odd when
+// these borders do not have the same color or if they are semi-transparent.
+// Therefor we add top and border bottoms to the `tr`s and left and right borders
+// to the `td`s or `th`s
+
+.table-bordered {
+ > :not(caption) > * {
+ border-width: $table-border-width 0;
+
+ // stylelint-disable-next-line selector-max-universal
+ > * {
+ border-width: 0 $table-border-width;
+ }
+ }
+}
+
+.table-borderless {
+ // stylelint-disable-next-line selector-max-universal
+ > :not(caption) > * > * {
+ border-bottom-width: 0;
+ }
+
+ > :not(:first-child) {
+ border-top-width: 0;
+ }
+}
+
+// Zebra-striping
+//
+// Default zebra-stripe styles (alternating gray and transparent backgrounds)
+
+// For rows
+.table-striped {
+ > tbody > tr:nth-of-type(#{$table-striped-order}) > * {
+ --#{$prefix}table-accent-bg: var(--#{$prefix}table-striped-bg);
+ color: var(--#{$prefix}table-striped-color);
+ }
+}
+
+// For columns
+.table-striped-columns {
+ > :not(caption) > tr > :nth-child(#{$table-striped-columns-order}) {
+ --#{$prefix}table-accent-bg: var(--#{$prefix}table-striped-bg);
+ color: var(--#{$prefix}table-striped-color);
+ }
+}
+
+// Active table
+//
+// The `.table-active` class can be added to highlight rows or cells
+
+.table-active {
+ --#{$prefix}table-accent-bg: var(--#{$prefix}table-active-bg);
+ color: var(--#{$prefix}table-active-color);
+}
+
+// Hover effect
+//
+// Placed here since it has to come after the potential zebra striping
+
+.table-hover {
+ > tbody > tr:hover > * {
+ --#{$prefix}table-accent-bg: var(--#{$prefix}table-hover-bg);
+ color: var(--#{$prefix}table-hover-color);
+ }
+}
+
+
+// Table variants
+//
+// Table variants set the table cell backgrounds, border colors
+// and the colors of the striped, hovered & active tables
+
+@each $color, $value in $table-variants {
+ @include table-variant($color, $value);
+}
+
+// Responsive tables
+//
+// Generate series of `.table-responsive-*` classes for configuring the screen
+// size of where your table will overflow.
+
+@each $breakpoint in map-keys($grid-breakpoints) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+
+ @include media-breakpoint-down($breakpoint) {
+ .table-responsive#{$infix} {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_toasts.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_toasts.scss
new file mode 100644
index 0000000000..c34e49b241
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_toasts.scss
@@ -0,0 +1,71 @@
+.toast {
+ // scss-docs-start toast-css-vars
+ --#{$prefix}toast-zindex: #{$zindex-toast};
+ --#{$prefix}toast-padding-x: #{$toast-padding-x};
+ --#{$prefix}toast-padding-y: #{$toast-padding-y};
+ --#{$prefix}toast-spacing: #{$toast-spacing};
+ --#{$prefix}toast-max-width: #{$toast-max-width};
+ @include rfs($toast-font-size, --#{$prefix}toast-font-size);
+ --#{$prefix}toast-color: #{$toast-color};
+ --#{$prefix}toast-bg: #{$toast-background-color};
+ --#{$prefix}toast-border-width: #{$toast-border-width};
+ --#{$prefix}toast-border-color: #{$toast-border-color};
+ --#{$prefix}toast-border-radius: #{$toast-border-radius};
+ --#{$prefix}toast-box-shadow: #{$toast-box-shadow};
+ --#{$prefix}toast-header-color: #{$toast-header-color};
+ --#{$prefix}toast-header-bg: #{$toast-header-background-color};
+ --#{$prefix}toast-header-border-color: #{$toast-header-border-color};
+ // scss-docs-end toast-css-vars
+
+ width: var(--#{$prefix}toast-max-width);
+ max-width: 100%;
+ @include font-size(var(--#{$prefix}toast-font-size));
+ color: var(--#{$prefix}toast-color);
+ pointer-events: auto;
+ background-color: var(--#{$prefix}toast-bg);
+ background-clip: padding-box;
+ border: var(--#{$prefix}toast-border-width) solid var(--#{$prefix}toast-border-color);
+ box-shadow: var(--#{$prefix}toast-box-shadow);
+ @include border-radius(var(--#{$prefix}toast-border-radius));
+
+ &.showing {
+ opacity: 0;
+ }
+
+ &:not(.show) {
+ display: none;
+ }
+}
+
+.toast-container {
+ position: absolute;
+ z-index: var(--#{$prefix}toast-zindex);
+ width: max-content;
+ max-width: 100%;
+ pointer-events: none;
+
+ > :not(:last-child) {
+ margin-bottom: var(--#{$prefix}toast-spacing);
+ }
+}
+
+.toast-header {
+ display: flex;
+ align-items: center;
+ padding: var(--#{$prefix}toast-padding-y) var(--#{$prefix}toast-padding-x);
+ color: var(--#{$prefix}toast-header-color);
+ background-color: var(--#{$prefix}toast-header-bg);
+ background-clip: padding-box;
+ border-bottom: var(--#{$prefix}toast-border-width) solid var(--#{$prefix}toast-header-border-color);
+ @include border-top-radius(calc(var(--#{$prefix}toast-border-radius) - var(--#{$prefix}toast-border-width)));
+
+ .btn-close {
+ margin-right: calc(-.5 * var(--#{$prefix}toast-padding-x)); // stylelint-disable-line function-disallowed-list
+ margin-left: var(--#{$prefix}toast-padding-x);
+ }
+}
+
+.toast-body {
+ padding: var(--#{$prefix}toast-padding-x);
+ word-wrap: break-word;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_tooltip.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_tooltip.scss
new file mode 100644
index 0000000000..7da3df3e00
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_tooltip.scss
@@ -0,0 +1,120 @@
+// Base class
+.tooltip {
+ // scss-docs-start tooltip-css-vars
+ --#{$prefix}tooltip-zindex: #{$zindex-tooltip};
+ --#{$prefix}tooltip-max-width: #{$tooltip-max-width};
+ --#{$prefix}tooltip-padding-x: #{$tooltip-padding-x};
+ --#{$prefix}tooltip-padding-y: #{$tooltip-padding-y};
+ --#{$prefix}tooltip-margin: #{$tooltip-margin};
+ @include rfs($tooltip-font-size, --#{$prefix}tooltip-font-size);
+ --#{$prefix}tooltip-color: #{$tooltip-color};
+ --#{$prefix}tooltip-bg: #{$tooltip-bg};
+ --#{$prefix}tooltip-border-radius: #{$tooltip-border-radius};
+ --#{$prefix}tooltip-opacity: #{$tooltip-opacity};
+ --#{$prefix}tooltip-arrow-width: #{$tooltip-arrow-width};
+ --#{$prefix}tooltip-arrow-height: #{$tooltip-arrow-height};
+ // scss-docs-end tooltip-css-vars
+
+ z-index: var(--#{$prefix}tooltip-zindex);
+ display: block;
+ padding: var(--#{$prefix}tooltip-arrow-height);
+ margin: var(--#{$prefix}tooltip-margin);
+ @include deprecate("`$tooltip-margin`", "v5", "v5.x", true);
+ // Our parent element can be arbitrary since tooltips are by default inserted as a sibling of their target element.
+ // So reset our font and text properties to avoid inheriting weird values.
+ @include reset-text();
+ @include font-size(var(--#{$prefix}tooltip-font-size));
+ // Allow breaking very long words so they don't overflow the tooltip's bounds
+ word-wrap: break-word;
+ opacity: 0;
+
+ &.show { opacity: var(--#{$prefix}tooltip-opacity); }
+
+ .tooltip-arrow {
+ display: block;
+ width: var(--#{$prefix}tooltip-arrow-width);
+ height: var(--#{$prefix}tooltip-arrow-height);
+
+ &::before {
+ position: absolute;
+ content: "";
+ border-color: transparent;
+ border-style: solid;
+ }
+ }
+}
+
+.bs-tooltip-top .tooltip-arrow {
+ bottom: 0;
+
+ &::before {
+ top: -1px;
+ border-width: var(--#{$prefix}tooltip-arrow-height) calc(var(--#{$prefix}tooltip-arrow-width) * .5) 0; // stylelint-disable-line function-disallowed-list
+ border-top-color: var(--#{$prefix}tooltip-bg);
+ }
+}
+
+/* rtl:begin:ignore */
+.bs-tooltip-end .tooltip-arrow {
+ left: 0;
+ width: var(--#{$prefix}tooltip-arrow-height);
+ height: var(--#{$prefix}tooltip-arrow-width);
+
+ &::before {
+ right: -1px;
+ border-width: calc(var(--#{$prefix}tooltip-arrow-width) * .5) var(--#{$prefix}tooltip-arrow-height) calc(var(--#{$prefix}tooltip-arrow-width) * .5) 0; // stylelint-disable-line function-disallowed-list
+ border-right-color: var(--#{$prefix}tooltip-bg);
+ }
+}
+
+/* rtl:end:ignore */
+
+.bs-tooltip-bottom .tooltip-arrow {
+ top: 0;
+
+ &::before {
+ bottom: -1px;
+ border-width: 0 calc(var(--#{$prefix}tooltip-arrow-width) * .5) var(--#{$prefix}tooltip-arrow-height); // stylelint-disable-line function-disallowed-list
+ border-bottom-color: var(--#{$prefix}tooltip-bg);
+ }
+}
+
+/* rtl:begin:ignore */
+.bs-tooltip-start .tooltip-arrow {
+ right: 0;
+ width: var(--#{$prefix}tooltip-arrow-height);
+ height: var(--#{$prefix}tooltip-arrow-width);
+
+ &::before {
+ left: -1px;
+ border-width: calc(var(--#{$prefix}tooltip-arrow-width) * .5) 0 calc(var(--#{$prefix}tooltip-arrow-width) * .5) var(--#{$prefix}tooltip-arrow-height); // stylelint-disable-line function-disallowed-list
+ border-left-color: var(--#{$prefix}tooltip-bg);
+ }
+}
+
+/* rtl:end:ignore */
+
+.bs-tooltip-auto {
+ &[data-popper-placement^="top"] {
+ @extend .bs-tooltip-top;
+ }
+ &[data-popper-placement^="right"] {
+ @extend .bs-tooltip-end;
+ }
+ &[data-popper-placement^="bottom"] {
+ @extend .bs-tooltip-bottom;
+ }
+ &[data-popper-placement^="left"] {
+ @extend .bs-tooltip-start;
+ }
+}
+
+// Wrapper for the tooltip content
+.tooltip-inner {
+ max-width: var(--#{$prefix}tooltip-max-width);
+ padding: var(--#{$prefix}tooltip-padding-y) var(--#{$prefix}tooltip-padding-x);
+ color: var(--#{$prefix}tooltip-color);
+ text-align: center;
+ background-color: var(--#{$prefix}tooltip-bg);
+ @include border-radius(var(--#{$prefix}tooltip-border-radius));
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_transitions.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_transitions.scss
new file mode 100644
index 0000000000..bfb26aa8ac
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_transitions.scss
@@ -0,0 +1,27 @@
+.fade {
+ @include transition($transition-fade);
+
+ &:not(.show) {
+ opacity: 0;
+ }
+}
+
+// scss-docs-start collapse-classes
+.collapse {
+ &:not(.show) {
+ display: none;
+ }
+}
+
+.collapsing {
+ height: 0;
+ overflow: hidden;
+ @include transition($transition-collapse);
+
+ &.collapse-horizontal {
+ width: 0;
+ height: auto;
+ @include transition($transition-collapse-width);
+ }
+}
+// scss-docs-end collapse-classes
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_type.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_type.scss
new file mode 100644
index 0000000000..37d64bf89c
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_type.scss
@@ -0,0 +1,106 @@
+//
+// Headings
+//
+.h1 {
+ @extend h1;
+}
+
+.h2 {
+ @extend h2;
+}
+
+.h3 {
+ @extend h3;
+}
+
+.h4 {
+ @extend h4;
+}
+
+.h5 {
+ @extend h5;
+}
+
+.h6 {
+ @extend h6;
+}
+
+
+.lead {
+ @include font-size($lead-font-size);
+ font-weight: $lead-font-weight;
+}
+
+// Type display classes
+@each $display, $font-size in $display-font-sizes {
+ .display-#{$display} {
+ @include font-size($font-size);
+ font-family: $display-font-family;
+ font-style: $display-font-style;
+ font-weight: $display-font-weight;
+ line-height: $display-line-height;
+ }
+}
+
+//
+// Emphasis
+//
+.small {
+ @extend small;
+}
+
+.mark {
+ @extend mark;
+}
+
+//
+// Lists
+//
+
+.list-unstyled {
+ @include list-unstyled();
+}
+
+// Inline turns list items into inline-block
+.list-inline {
+ @include list-unstyled();
+}
+.list-inline-item {
+ display: inline-block;
+
+ &:not(:last-child) {
+ margin-right: $list-inline-padding;
+ }
+}
+
+
+//
+// Misc
+//
+
+// Builds on `abbr`
+.initialism {
+ @include font-size($initialism-font-size);
+ text-transform: uppercase;
+}
+
+// Blockquotes
+.blockquote {
+ margin-bottom: $blockquote-margin-y;
+ @include font-size($blockquote-font-size);
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+}
+
+.blockquote-footer {
+ margin-top: -$blockquote-margin-y;
+ margin-bottom: $blockquote-margin-y;
+ @include font-size($blockquote-footer-font-size);
+ color: $blockquote-footer-color;
+
+ &::before {
+ content: "\2014\00A0"; // em dash, nbsp
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_utilities.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_utilities.scss
new file mode 100644
index 0000000000..1e0d141acc
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_utilities.scss
@@ -0,0 +1,647 @@
+// Utilities
+
+$utilities: () !default;
+// stylelint-disable-next-line scss/dollar-variable-default
+$utilities: map-merge(
+ (
+ // scss-docs-start utils-vertical-align
+ "align": (
+ property: vertical-align,
+ class: align,
+ values: baseline top middle bottom text-bottom text-top
+ ),
+ // scss-docs-end utils-vertical-align
+ // scss-docs-start utils-float
+ "float": (
+ responsive: true,
+ property: float,
+ values: (
+ start: left,
+ end: right,
+ none: none,
+ )
+ ),
+ // scss-docs-end utils-float
+ // Opacity utilities
+ // scss-docs-start utils-opacity
+ "opacity": (
+ property: opacity,
+ values: (
+ 0: 0,
+ 25: .25,
+ 50: .5,
+ 75: .75,
+ 100: 1,
+ )
+ ),
+ // scss-docs-end utils-opacity
+ // scss-docs-start utils-overflow
+ "overflow": (
+ property: overflow,
+ values: auto hidden visible scroll,
+ ),
+ // scss-docs-end utils-overflow
+ // scss-docs-start utils-display
+ "display": (
+ responsive: true,
+ print: true,
+ property: display,
+ class: d,
+ values: inline inline-block block grid table table-row table-cell flex inline-flex none
+ ),
+ // scss-docs-end utils-display
+ // scss-docs-start utils-shadow
+ "shadow": (
+ property: box-shadow,
+ class: shadow,
+ values: (
+ null: $box-shadow,
+ sm: $box-shadow-sm,
+ lg: $box-shadow-lg,
+ none: none,
+ )
+ ),
+ // scss-docs-end utils-shadow
+ // scss-docs-start utils-position
+ "position": (
+ property: position,
+ values: static relative absolute fixed sticky
+ ),
+ "top": (
+ property: top,
+ values: $position-values
+ ),
+ "bottom": (
+ property: bottom,
+ values: $position-values
+ ),
+ "start": (
+ property: left,
+ class: start,
+ values: $position-values
+ ),
+ "end": (
+ property: right,
+ class: end,
+ values: $position-values
+ ),
+ "translate-middle": (
+ property: transform,
+ class: translate-middle,
+ values: (
+ null: translate(-50%, -50%),
+ x: translateX(-50%),
+ y: translateY(-50%),
+ )
+ ),
+ // scss-docs-end utils-position
+ // scss-docs-start utils-borders
+ "border": (
+ property: border,
+ values: (
+ null: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color),
+ 0: 0,
+ )
+ ),
+ "border-top": (
+ property: border-top,
+ values: (
+ null: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color),
+ 0: 0,
+ )
+ ),
+ "border-end": (
+ property: border-right,
+ class: border-end,
+ values: (
+ null: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color),
+ 0: 0,
+ )
+ ),
+ "border-bottom": (
+ property: border-bottom,
+ values: (
+ null: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color),
+ 0: 0,
+ )
+ ),
+ "border-start": (
+ property: border-left,
+ class: border-start,
+ values: (
+ null: var(--#{$prefix}border-width) var(--#{$prefix}border-style) var(--#{$prefix}border-color),
+ 0: 0,
+ )
+ ),
+ "border-color": (
+ property: border-color,
+ class: border,
+ local-vars: (
+ "border-opacity": 1
+ ),
+ values: $utilities-border-colors
+ ),
+ "border-width": (
+ css-var: true,
+ css-variable-name: border-width,
+ class: border,
+ values: $border-widths
+ ),
+ "border-opacity": (
+ css-var: true,
+ class: border-opacity,
+ values: (
+ 10: .1,
+ 25: .25,
+ 50: .5,
+ 75: .75,
+ 100: 1
+ )
+ ),
+ // scss-docs-end utils-borders
+ // Sizing utilities
+ // scss-docs-start utils-sizing
+ "width": (
+ property: width,
+ class: w,
+ values: (
+ 25: 25%,
+ 50: 50%,
+ 75: 75%,
+ 100: 100%,
+ auto: auto
+ )
+ ),
+ "max-width": (
+ property: max-width,
+ class: mw,
+ values: (100: 100%)
+ ),
+ "viewport-width": (
+ property: width,
+ class: vw,
+ values: (100: 100vw)
+ ),
+ "min-viewport-width": (
+ property: min-width,
+ class: min-vw,
+ values: (100: 100vw)
+ ),
+ "height": (
+ property: height,
+ class: h,
+ values: (
+ 25: 25%,
+ 50: 50%,
+ 75: 75%,
+ 100: 100%,
+ auto: auto
+ )
+ ),
+ "max-height": (
+ property: max-height,
+ class: mh,
+ values: (100: 100%)
+ ),
+ "viewport-height": (
+ property: height,
+ class: vh,
+ values: (100: 100vh)
+ ),
+ "min-viewport-height": (
+ property: min-height,
+ class: min-vh,
+ values: (100: 100vh)
+ ),
+ // scss-docs-end utils-sizing
+ // Flex utilities
+ // scss-docs-start utils-flex
+ "flex": (
+ responsive: true,
+ property: flex,
+ values: (fill: 1 1 auto)
+ ),
+ "flex-direction": (
+ responsive: true,
+ property: flex-direction,
+ class: flex,
+ values: row column row-reverse column-reverse
+ ),
+ "flex-grow": (
+ responsive: true,
+ property: flex-grow,
+ class: flex,
+ values: (
+ grow-0: 0,
+ grow-1: 1,
+ )
+ ),
+ "flex-shrink": (
+ responsive: true,
+ property: flex-shrink,
+ class: flex,
+ values: (
+ shrink-0: 0,
+ shrink-1: 1,
+ )
+ ),
+ "flex-wrap": (
+ responsive: true,
+ property: flex-wrap,
+ class: flex,
+ values: wrap nowrap wrap-reverse
+ ),
+ "justify-content": (
+ responsive: true,
+ property: justify-content,
+ values: (
+ start: flex-start,
+ end: flex-end,
+ center: center,
+ between: space-between,
+ around: space-around,
+ evenly: space-evenly,
+ )
+ ),
+ "align-items": (
+ responsive: true,
+ property: align-items,
+ values: (
+ start: flex-start,
+ end: flex-end,
+ center: center,
+ baseline: baseline,
+ stretch: stretch,
+ )
+ ),
+ "align-content": (
+ responsive: true,
+ property: align-content,
+ values: (
+ start: flex-start,
+ end: flex-end,
+ center: center,
+ between: space-between,
+ around: space-around,
+ stretch: stretch,
+ )
+ ),
+ "align-self": (
+ responsive: true,
+ property: align-self,
+ values: (
+ auto: auto,
+ start: flex-start,
+ end: flex-end,
+ center: center,
+ baseline: baseline,
+ stretch: stretch,
+ )
+ ),
+ "order": (
+ responsive: true,
+ property: order,
+ values: (
+ first: -1,
+ 0: 0,
+ 1: 1,
+ 2: 2,
+ 3: 3,
+ 4: 4,
+ 5: 5,
+ last: 6,
+ ),
+ ),
+ // scss-docs-end utils-flex
+ // Margin utilities
+ // scss-docs-start utils-spacing
+ "margin": (
+ responsive: true,
+ property: margin,
+ class: m,
+ values: map-merge($spacers, (auto: auto))
+ ),
+ "margin-x": (
+ responsive: true,
+ property: margin-right margin-left,
+ class: mx,
+ values: map-merge($spacers, (auto: auto))
+ ),
+ "margin-y": (
+ responsive: true,
+ property: margin-top margin-bottom,
+ class: my,
+ values: map-merge($spacers, (auto: auto))
+ ),
+ "margin-top": (
+ responsive: true,
+ property: margin-top,
+ class: mt,
+ values: map-merge($spacers, (auto: auto))
+ ),
+ "margin-end": (
+ responsive: true,
+ property: margin-right,
+ class: me,
+ values: map-merge($spacers, (auto: auto))
+ ),
+ "margin-bottom": (
+ responsive: true,
+ property: margin-bottom,
+ class: mb,
+ values: map-merge($spacers, (auto: auto))
+ ),
+ "margin-start": (
+ responsive: true,
+ property: margin-left,
+ class: ms,
+ values: map-merge($spacers, (auto: auto))
+ ),
+ // Negative margin utilities
+ "negative-margin": (
+ responsive: true,
+ property: margin,
+ class: m,
+ values: $negative-spacers
+ ),
+ "negative-margin-x": (
+ responsive: true,
+ property: margin-right margin-left,
+ class: mx,
+ values: $negative-spacers
+ ),
+ "negative-margin-y": (
+ responsive: true,
+ property: margin-top margin-bottom,
+ class: my,
+ values: $negative-spacers
+ ),
+ "negative-margin-top": (
+ responsive: true,
+ property: margin-top,
+ class: mt,
+ values: $negative-spacers
+ ),
+ "negative-margin-end": (
+ responsive: true,
+ property: margin-right,
+ class: me,
+ values: $negative-spacers
+ ),
+ "negative-margin-bottom": (
+ responsive: true,
+ property: margin-bottom,
+ class: mb,
+ values: $negative-spacers
+ ),
+ "negative-margin-start": (
+ responsive: true,
+ property: margin-left,
+ class: ms,
+ values: $negative-spacers
+ ),
+ // Padding utilities
+ "padding": (
+ responsive: true,
+ property: padding,
+ class: p,
+ values: $spacers
+ ),
+ "padding-x": (
+ responsive: true,
+ property: padding-right padding-left,
+ class: px,
+ values: $spacers
+ ),
+ "padding-y": (
+ responsive: true,
+ property: padding-top padding-bottom,
+ class: py,
+ values: $spacers
+ ),
+ "padding-top": (
+ responsive: true,
+ property: padding-top,
+ class: pt,
+ values: $spacers
+ ),
+ "padding-end": (
+ responsive: true,
+ property: padding-right,
+ class: pe,
+ values: $spacers
+ ),
+ "padding-bottom": (
+ responsive: true,
+ property: padding-bottom,
+ class: pb,
+ values: $spacers
+ ),
+ "padding-start": (
+ responsive: true,
+ property: padding-left,
+ class: ps,
+ values: $spacers
+ ),
+ // Gap utility
+ "gap": (
+ responsive: true,
+ property: gap,
+ class: gap,
+ values: $spacers
+ ),
+ // scss-docs-end utils-spacing
+ // Text
+ // scss-docs-start utils-text
+ "font-family": (
+ property: font-family,
+ class: font,
+ values: (monospace: var(--#{$prefix}font-monospace))
+ ),
+ "font-size": (
+ rfs: true,
+ property: font-size,
+ class: fs,
+ values: $font-sizes
+ ),
+ "font-style": (
+ property: font-style,
+ class: fst,
+ values: italic normal
+ ),
+ "font-weight": (
+ property: font-weight,
+ class: fw,
+ values: (
+ light: $font-weight-light,
+ lighter: $font-weight-lighter,
+ normal: $font-weight-normal,
+ bold: $font-weight-bold,
+ semibold: $font-weight-semibold,
+ bolder: $font-weight-bolder
+ )
+ ),
+ "line-height": (
+ property: line-height,
+ class: lh,
+ values: (
+ 1: 1,
+ sm: $line-height-sm,
+ base: $line-height-base,
+ lg: $line-height-lg,
+ )
+ ),
+ "text-align": (
+ responsive: true,
+ property: text-align,
+ class: text,
+ values: (
+ start: left,
+ end: right,
+ center: center,
+ )
+ ),
+ "text-decoration": (
+ property: text-decoration,
+ values: none underline line-through
+ ),
+ "text-transform": (
+ property: text-transform,
+ class: text,
+ values: lowercase uppercase capitalize
+ ),
+ "white-space": (
+ property: white-space,
+ class: text,
+ values: (
+ wrap: normal,
+ nowrap: nowrap,
+ )
+ ),
+ "word-wrap": (
+ property: word-wrap word-break,
+ class: text,
+ values: (break: break-word),
+ rtl: false
+ ),
+ // scss-docs-end utils-text
+ // scss-docs-start utils-color
+ "color": (
+ property: color,
+ class: text,
+ local-vars: (
+ "text-opacity": 1
+ ),
+ values: map-merge(
+ $utilities-text-colors,
+ (
+ "muted": $text-muted,
+ "black-50": rgba($black, .5), // deprecated
+ "white-50": rgba($white, .5), // deprecated
+ "reset": inherit,
+ )
+ )
+ ),
+ "text-opacity": (
+ css-var: true,
+ class: text-opacity,
+ values: (
+ 25: .25,
+ 50: .5,
+ 75: .75,
+ 100: 1
+ )
+ ),
+ // scss-docs-end utils-color
+ // scss-docs-start utils-bg-color
+ "background-color": (
+ property: background-color,
+ class: bg,
+ local-vars: (
+ "bg-opacity": 1
+ ),
+ values: map-merge(
+ $utilities-bg-colors,
+ (
+ "transparent": transparent
+ )
+ )
+ ),
+ "bg-opacity": (
+ css-var: true,
+ class: bg-opacity,
+ values: (
+ 10: .1,
+ 25: .25,
+ 50: .5,
+ 75: .75,
+ 100: 1
+ )
+ ),
+ // scss-docs-end utils-bg-color
+ "gradient": (
+ property: background-image,
+ class: bg,
+ values: (gradient: var(--#{$prefix}gradient))
+ ),
+ // scss-docs-start utils-interaction
+ "user-select": (
+ property: user-select,
+ values: all auto none
+ ),
+ "pointer-events": (
+ property: pointer-events,
+ class: pe,
+ values: none auto,
+ ),
+ // scss-docs-end utils-interaction
+ // scss-docs-start utils-border-radius
+ "rounded": (
+ property: border-radius,
+ class: rounded,
+ values: (
+ null: var(--#{$prefix}border-radius),
+ 0: 0,
+ 1: var(--#{$prefix}border-radius-sm),
+ 2: var(--#{$prefix}border-radius),
+ 3: var(--#{$prefix}border-radius-lg),
+ 4: var(--#{$prefix}border-radius-xl),
+ 5: var(--#{$prefix}border-radius-2xl),
+ circle: 50%,
+ pill: var(--#{$prefix}border-radius-pill)
+ )
+ ),
+ "rounded-top": (
+ property: border-top-left-radius border-top-right-radius,
+ class: rounded-top,
+ values: (null: var(--#{$prefix}border-radius))
+ ),
+ "rounded-end": (
+ property: border-top-right-radius border-bottom-right-radius,
+ class: rounded-end,
+ values: (null: var(--#{$prefix}border-radius))
+ ),
+ "rounded-bottom": (
+ property: border-bottom-right-radius border-bottom-left-radius,
+ class: rounded-bottom,
+ values: (null: var(--#{$prefix}border-radius))
+ ),
+ "rounded-start": (
+ property: border-bottom-left-radius border-top-left-radius,
+ class: rounded-start,
+ values: (null: var(--#{$prefix}border-radius))
+ ),
+ // scss-docs-end utils-border-radius
+ // scss-docs-start utils-visibility
+ "visibility": (
+ property: visibility,
+ class: null,
+ values: (
+ visible: visible,
+ invisible: hidden,
+ )
+ )
+ // scss-docs-end utils-visibility
+ ),
+ $utilities
+);
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/_variables.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/_variables.scss
new file mode 100644
index 0000000000..07ce922f33
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/_variables.scss
@@ -0,0 +1,1634 @@
+// Variables
+//
+// Variables should follow the `$component-state-property-size` formula for
+// consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs.
+
+// Color system
+
+// scss-docs-start gray-color-variables
+$white: #fff !default;
+$gray-100: #f8f9fa !default;
+$gray-200: #e9ecef !default;
+$gray-300: #dee2e6 !default;
+$gray-400: #ced4da !default;
+$gray-500: #adb5bd !default;
+$gray-600: #6c757d !default;
+$gray-700: #495057 !default;
+$gray-800: #343a40 !default;
+$gray-900: #212529 !default;
+$black: #000 !default;
+// scss-docs-end gray-color-variables
+
+// fusv-disable
+// scss-docs-start gray-colors-map
+$grays: (
+ "100": $gray-100,
+ "200": $gray-200,
+ "300": $gray-300,
+ "400": $gray-400,
+ "500": $gray-500,
+ "600": $gray-600,
+ "700": $gray-700,
+ "800": $gray-800,
+ "900": $gray-900
+) !default;
+// scss-docs-end gray-colors-map
+// fusv-enable
+
+// scss-docs-start color-variables
+$blue: #0d6efd !default;
+$indigo: #6610f2 !default;
+$purple: #6f42c1 !default;
+$pink: #d63384 !default;
+$red: #dc3545 !default;
+$orange: #fd7e14 !default;
+$yellow: #ffc107 !default;
+$green: #198754 !default;
+$teal: #20c997 !default;
+$cyan: #0dcaf0 !default;
+// scss-docs-end color-variables
+
+// scss-docs-start colors-map
+$colors: (
+ "blue": $blue,
+ "indigo": $indigo,
+ "purple": $purple,
+ "pink": $pink,
+ "red": $red,
+ "orange": $orange,
+ "yellow": $yellow,
+ "green": $green,
+ "teal": $teal,
+ "cyan": $cyan,
+ "black": $black,
+ "white": $white,
+ "gray": $gray-600,
+ "gray-dark": $gray-800
+) !default;
+// scss-docs-end colors-map
+
+// The contrast ratio to reach against white, to determine if color changes from "light" to "dark". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.
+// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast
+$min-contrast-ratio: 4.5 !default;
+
+// Customize the light and dark text colors for use in our color contrast function.
+$color-contrast-dark: $black !default;
+$color-contrast-light: $white !default;
+
+// fusv-disable
+$blue-100: tint-color($blue, 80%) !default;
+$blue-200: tint-color($blue, 60%) !default;
+$blue-300: tint-color($blue, 40%) !default;
+$blue-400: tint-color($blue, 20%) !default;
+$blue-500: $blue !default;
+$blue-600: shade-color($blue, 20%) !default;
+$blue-700: shade-color($blue, 40%) !default;
+$blue-800: shade-color($blue, 60%) !default;
+$blue-900: shade-color($blue, 80%) !default;
+
+$indigo-100: tint-color($indigo, 80%) !default;
+$indigo-200: tint-color($indigo, 60%) !default;
+$indigo-300: tint-color($indigo, 40%) !default;
+$indigo-400: tint-color($indigo, 20%) !default;
+$indigo-500: $indigo !default;
+$indigo-600: shade-color($indigo, 20%) !default;
+$indigo-700: shade-color($indigo, 40%) !default;
+$indigo-800: shade-color($indigo, 60%) !default;
+$indigo-900: shade-color($indigo, 80%) !default;
+
+$purple-100: tint-color($purple, 80%) !default;
+$purple-200: tint-color($purple, 60%) !default;
+$purple-300: tint-color($purple, 40%) !default;
+$purple-400: tint-color($purple, 20%) !default;
+$purple-500: $purple !default;
+$purple-600: shade-color($purple, 20%) !default;
+$purple-700: shade-color($purple, 40%) !default;
+$purple-800: shade-color($purple, 60%) !default;
+$purple-900: shade-color($purple, 80%) !default;
+
+$pink-100: tint-color($pink, 80%) !default;
+$pink-200: tint-color($pink, 60%) !default;
+$pink-300: tint-color($pink, 40%) !default;
+$pink-400: tint-color($pink, 20%) !default;
+$pink-500: $pink !default;
+$pink-600: shade-color($pink, 20%) !default;
+$pink-700: shade-color($pink, 40%) !default;
+$pink-800: shade-color($pink, 60%) !default;
+$pink-900: shade-color($pink, 80%) !default;
+
+$red-100: tint-color($red, 80%) !default;
+$red-200: tint-color($red, 60%) !default;
+$red-300: tint-color($red, 40%) !default;
+$red-400: tint-color($red, 20%) !default;
+$red-500: $red !default;
+$red-600: shade-color($red, 20%) !default;
+$red-700: shade-color($red, 40%) !default;
+$red-800: shade-color($red, 60%) !default;
+$red-900: shade-color($red, 80%) !default;
+
+$orange-100: tint-color($orange, 80%) !default;
+$orange-200: tint-color($orange, 60%) !default;
+$orange-300: tint-color($orange, 40%) !default;
+$orange-400: tint-color($orange, 20%) !default;
+$orange-500: $orange !default;
+$orange-600: shade-color($orange, 20%) !default;
+$orange-700: shade-color($orange, 40%) !default;
+$orange-800: shade-color($orange, 60%) !default;
+$orange-900: shade-color($orange, 80%) !default;
+
+$yellow-100: tint-color($yellow, 80%) !default;
+$yellow-200: tint-color($yellow, 60%) !default;
+$yellow-300: tint-color($yellow, 40%) !default;
+$yellow-400: tint-color($yellow, 20%) !default;
+$yellow-500: $yellow !default;
+$yellow-600: shade-color($yellow, 20%) !default;
+$yellow-700: shade-color($yellow, 40%) !default;
+$yellow-800: shade-color($yellow, 60%) !default;
+$yellow-900: shade-color($yellow, 80%) !default;
+
+$green-100: tint-color($green, 80%) !default;
+$green-200: tint-color($green, 60%) !default;
+$green-300: tint-color($green, 40%) !default;
+$green-400: tint-color($green, 20%) !default;
+$green-500: $green !default;
+$green-600: shade-color($green, 20%) !default;
+$green-700: shade-color($green, 40%) !default;
+$green-800: shade-color($green, 60%) !default;
+$green-900: shade-color($green, 80%) !default;
+
+$teal-100: tint-color($teal, 80%) !default;
+$teal-200: tint-color($teal, 60%) !default;
+$teal-300: tint-color($teal, 40%) !default;
+$teal-400: tint-color($teal, 20%) !default;
+$teal-500: $teal !default;
+$teal-600: shade-color($teal, 20%) !default;
+$teal-700: shade-color($teal, 40%) !default;
+$teal-800: shade-color($teal, 60%) !default;
+$teal-900: shade-color($teal, 80%) !default;
+
+$cyan-100: tint-color($cyan, 80%) !default;
+$cyan-200: tint-color($cyan, 60%) !default;
+$cyan-300: tint-color($cyan, 40%) !default;
+$cyan-400: tint-color($cyan, 20%) !default;
+$cyan-500: $cyan !default;
+$cyan-600: shade-color($cyan, 20%) !default;
+$cyan-700: shade-color($cyan, 40%) !default;
+$cyan-800: shade-color($cyan, 60%) !default;
+$cyan-900: shade-color($cyan, 80%) !default;
+
+$blues: (
+ "blue-100": $blue-100,
+ "blue-200": $blue-200,
+ "blue-300": $blue-300,
+ "blue-400": $blue-400,
+ "blue-500": $blue-500,
+ "blue-600": $blue-600,
+ "blue-700": $blue-700,
+ "blue-800": $blue-800,
+ "blue-900": $blue-900
+) !default;
+
+$indigos: (
+ "indigo-100": $indigo-100,
+ "indigo-200": $indigo-200,
+ "indigo-300": $indigo-300,
+ "indigo-400": $indigo-400,
+ "indigo-500": $indigo-500,
+ "indigo-600": $indigo-600,
+ "indigo-700": $indigo-700,
+ "indigo-800": $indigo-800,
+ "indigo-900": $indigo-900
+) !default;
+
+$purples: (
+ "purple-100": $purple-100,
+ "purple-200": $purple-200,
+ "purple-300": $purple-300,
+ "purple-400": $purple-400,
+ "purple-500": $purple-500,
+ "purple-600": $purple-600,
+ "purple-700": $purple-700,
+ "purple-800": $purple-800,
+ "purple-900": $purple-900
+) !default;
+
+$pinks: (
+ "pink-100": $pink-100,
+ "pink-200": $pink-200,
+ "pink-300": $pink-300,
+ "pink-400": $pink-400,
+ "pink-500": $pink-500,
+ "pink-600": $pink-600,
+ "pink-700": $pink-700,
+ "pink-800": $pink-800,
+ "pink-900": $pink-900
+) !default;
+
+$reds: (
+ "red-100": $red-100,
+ "red-200": $red-200,
+ "red-300": $red-300,
+ "red-400": $red-400,
+ "red-500": $red-500,
+ "red-600": $red-600,
+ "red-700": $red-700,
+ "red-800": $red-800,
+ "red-900": $red-900
+) !default;
+
+$oranges: (
+ "orange-100": $orange-100,
+ "orange-200": $orange-200,
+ "orange-300": $orange-300,
+ "orange-400": $orange-400,
+ "orange-500": $orange-500,
+ "orange-600": $orange-600,
+ "orange-700": $orange-700,
+ "orange-800": $orange-800,
+ "orange-900": $orange-900
+) !default;
+
+$yellows: (
+ "yellow-100": $yellow-100,
+ "yellow-200": $yellow-200,
+ "yellow-300": $yellow-300,
+ "yellow-400": $yellow-400,
+ "yellow-500": $yellow-500,
+ "yellow-600": $yellow-600,
+ "yellow-700": $yellow-700,
+ "yellow-800": $yellow-800,
+ "yellow-900": $yellow-900
+) !default;
+
+$greens: (
+ "green-100": $green-100,
+ "green-200": $green-200,
+ "green-300": $green-300,
+ "green-400": $green-400,
+ "green-500": $green-500,
+ "green-600": $green-600,
+ "green-700": $green-700,
+ "green-800": $green-800,
+ "green-900": $green-900
+) !default;
+
+$teals: (
+ "teal-100": $teal-100,
+ "teal-200": $teal-200,
+ "teal-300": $teal-300,
+ "teal-400": $teal-400,
+ "teal-500": $teal-500,
+ "teal-600": $teal-600,
+ "teal-700": $teal-700,
+ "teal-800": $teal-800,
+ "teal-900": $teal-900
+) !default;
+
+$cyans: (
+ "cyan-100": $cyan-100,
+ "cyan-200": $cyan-200,
+ "cyan-300": $cyan-300,
+ "cyan-400": $cyan-400,
+ "cyan-500": $cyan-500,
+ "cyan-600": $cyan-600,
+ "cyan-700": $cyan-700,
+ "cyan-800": $cyan-800,
+ "cyan-900": $cyan-900
+) !default;
+// fusv-enable
+
+// scss-docs-start theme-color-variables
+$primary: $blue !default;
+$secondary: $gray-600 !default;
+$success: $green !default;
+$info: $cyan !default;
+$warning: $yellow !default;
+$danger: $red !default;
+$light: $gray-100 !default;
+$dark: $gray-900 !default;
+// scss-docs-end theme-color-variables
+
+// scss-docs-start theme-colors-map
+$theme-colors: (
+ "primary": $primary,
+ "secondary": $secondary,
+ "success": $success,
+ "info": $info,
+ "warning": $warning,
+ "danger": $danger,
+ "light": $light,
+ "dark": $dark
+) !default;
+// scss-docs-end theme-colors-map
+
+// Characters which are escaped by the escape-svg function
+$escaped-characters: (
+ ("<", "%3c"),
+ (">", "%3e"),
+ ("#", "%23"),
+ ("(", "%28"),
+ (")", "%29"),
+) !default;
+
+// Options
+//
+// Quickly modify global styling by enabling or disabling optional features.
+
+$enable-caret: true !default;
+$enable-rounded: true !default;
+$enable-shadows: false !default;
+$enable-gradients: false !default;
+$enable-transitions: true !default;
+$enable-reduced-motion: true !default;
+$enable-smooth-scroll: true !default;
+$enable-grid-classes: true !default;
+$enable-container-classes: true !default;
+$enable-cssgrid: false !default;
+$enable-button-pointers: true !default;
+$enable-rfs: true !default;
+$enable-validation-icons: true !default;
+$enable-negative-margins: false !default;
+$enable-deprecation-messages: true !default;
+$enable-important-utilities: true !default;
+
+// Prefix for :root CSS variables
+
+$variable-prefix: bs- !default; // Deprecated in v5.2.0 for the shorter `$prefix`
+$prefix: $variable-prefix !default;
+
+// Gradient
+//
+// The gradient which is added to components if `$enable-gradients` is `true`
+// This gradient is also added to elements with `.bg-gradient`
+// scss-docs-start variable-gradient
+$gradient: linear-gradient(180deg, rgba($white, .15), rgba($white, 0)) !default;
+// scss-docs-end variable-gradient
+
+// Spacing
+//
+// Control the default styling of most Bootstrap elements by modifying these
+// variables. Mostly focused on spacing.
+// You can add more entries to the $spacers map, should you need more variation.
+
+// scss-docs-start spacer-variables-maps
+$spacer: 1rem !default;
+$spacers: (
+ 0: 0,
+ 1: $spacer * .25,
+ 2: $spacer * .5,
+ 3: $spacer,
+ 4: $spacer * 1.5,
+ 5: $spacer * 3,
+) !default;
+// scss-docs-end spacer-variables-maps
+
+// Position
+//
+// Define the edge positioning anchors of the position utilities.
+
+// scss-docs-start position-map
+$position-values: (
+ 0: 0,
+ 50: 50%,
+ 100: 100%
+) !default;
+// scss-docs-end position-map
+
+// Body
+//
+// Settings for the `<body>` element.
+
+$body-bg: $white !default;
+$body-color: $gray-900 !default;
+$body-text-align: null !default;
+
+// Links
+//
+// Style anchor elements.
+
+$link-color: $primary !default;
+$link-decoration: underline !default;
+$link-shade-percentage: 20% !default;
+$link-hover-color: shift-color($link-color, $link-shade-percentage) !default;
+$link-hover-decoration: null !default;
+
+$stretched-link-pseudo-element: after !default;
+$stretched-link-z-index: 1 !default;
+
+// Paragraphs
+//
+// Style p element.
+
+$paragraph-margin-bottom: 1rem !default;
+
+
+// Grid breakpoints
+//
+// Define the minimum dimensions at which your layout will change,
+// adapting to different screen sizes, for use in media queries.
+
+// scss-docs-start grid-breakpoints
+$grid-breakpoints: (
+ xs: 0,
+ sm: 576px,
+ md: 768px,
+ lg: 992px,
+ xl: 1200px,
+ xxl: 1400px
+) !default;
+// scss-docs-end grid-breakpoints
+
+@include _assert-ascending($grid-breakpoints, "$grid-breakpoints");
+@include _assert-starts-at-zero($grid-breakpoints, "$grid-breakpoints");
+
+
+// Grid containers
+//
+// Define the maximum width of `.container` for different screen sizes.
+
+// scss-docs-start container-max-widths
+$container-max-widths: (
+ sm: 540px,
+ md: 720px,
+ lg: 960px,
+ xl: 1140px,
+ xxl: 1320px
+) !default;
+// scss-docs-end container-max-widths
+
+@include _assert-ascending($container-max-widths, "$container-max-widths");
+
+
+// Grid columns
+//
+// Set the number of columns and specify the width of the gutters.
+
+$grid-columns: 12 !default;
+$grid-gutter-width: 1.5rem !default;
+$grid-row-columns: 6 !default;
+
+// Container padding
+
+$container-padding-x: $grid-gutter-width !default;
+
+
+// Components
+//
+// Define common padding and border radius sizes and more.
+
+// scss-docs-start border-variables
+$border-width: 1px !default;
+$border-widths: (
+ 1: 1px,
+ 2: 2px,
+ 3: 3px,
+ 4: 4px,
+ 5: 5px
+) !default;
+
+$border-style: solid !default;
+$border-color: $gray-300 !default;
+$border-color-translucent: rgba($black, .175) !default;
+// scss-docs-end border-variables
+
+// scss-docs-start border-radius-variables
+$border-radius: .375rem !default;
+$border-radius-sm: .25rem !default;
+$border-radius-lg: .5rem !default;
+$border-radius-xl: 1rem !default;
+$border-radius-2xl: 2rem !default;
+$border-radius-pill: 50rem !default;
+// scss-docs-end border-radius-variables
+
+// scss-docs-start box-shadow-variables
+$box-shadow: 0 .5rem 1rem rgba($black, .15) !default;
+$box-shadow-sm: 0 .125rem .25rem rgba($black, .075) !default;
+$box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default;
+$box-shadow-inset: inset 0 1px 2px rgba($black, .075) !default;
+// scss-docs-end box-shadow-variables
+
+$component-active-color: $white !default;
+$component-active-bg: $primary !default;
+
+// scss-docs-start caret-variables
+$caret-width: .3em !default;
+$caret-vertical-align: $caret-width * .85 !default;
+$caret-spacing: $caret-width * .85 !default;
+// scss-docs-end caret-variables
+
+$transition-base: all .2s ease-in-out !default;
+$transition-fade: opacity .15s linear !default;
+// scss-docs-start collapse-transition
+$transition-collapse: height .35s ease !default;
+$transition-collapse-width: width .35s ease !default;
+// scss-docs-end collapse-transition
+
+// stylelint-disable function-disallowed-list
+// scss-docs-start aspect-ratios
+$aspect-ratios: (
+ "1x1": 100%,
+ "4x3": calc(3 / 4 * 100%),
+ "16x9": calc(9 / 16 * 100%),
+ "21x9": calc(9 / 21 * 100%)
+) !default;
+// scss-docs-end aspect-ratios
+// stylelint-enable function-disallowed-list
+
+// Typography
+//
+// Font, line-height, and color for body text, headings, and more.
+
+// scss-docs-start font-variables
+// stylelint-disable value-keyword-case
+$font-family-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default;
+$font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !default;
+// stylelint-enable value-keyword-case
+$font-family-base: var(--#{$prefix}font-sans-serif) !default;
+$font-family-code: var(--#{$prefix}font-monospace) !default;
+
+// $font-size-root affects the value of `rem`, which is used for as well font sizes, paddings, and margins
+// $font-size-base affects the font size of the body text
+$font-size-root: null !default;
+$font-size-base: 1rem !default; // Assumes the browser default, typically `16px`
+$font-size-sm: $font-size-base * .875 !default;
+$font-size-lg: $font-size-base * 1.25 !default;
+
+$font-weight-lighter: lighter !default;
+$font-weight-light: 300 !default;
+$font-weight-normal: 400 !default;
+$font-weight-semibold: 600 !default;
+$font-weight-bold: 700 !default;
+$font-weight-bolder: bolder !default;
+
+$font-weight-base: $font-weight-normal !default;
+
+$line-height-base: 1.5 !default;
+$line-height-sm: 1.25 !default;
+$line-height-lg: 2 !default;
+
+$h1-font-size: $font-size-base * 2.5 !default;
+$h2-font-size: $font-size-base * 2 !default;
+$h3-font-size: $font-size-base * 1.75 !default;
+$h4-font-size: $font-size-base * 1.5 !default;
+$h5-font-size: $font-size-base * 1.25 !default;
+$h6-font-size: $font-size-base !default;
+// scss-docs-end font-variables
+
+// scss-docs-start font-sizes
+$font-sizes: (
+ 1: $h1-font-size,
+ 2: $h2-font-size,
+ 3: $h3-font-size,
+ 4: $h4-font-size,
+ 5: $h5-font-size,
+ 6: $h6-font-size
+) !default;
+// scss-docs-end font-sizes
+
+// scss-docs-start headings-variables
+$headings-margin-bottom: $spacer * .5 !default;
+$headings-font-family: null !default;
+$headings-font-style: null !default;
+$headings-font-weight: 500 !default;
+$headings-line-height: 1.2 !default;
+$headings-color: null !default;
+// scss-docs-end headings-variables
+
+// scss-docs-start display-headings
+$display-font-sizes: (
+ 1: 5rem,
+ 2: 4.5rem,
+ 3: 4rem,
+ 4: 3.5rem,
+ 5: 3rem,
+ 6: 2.5rem
+) !default;
+
+$display-font-family: null !default;
+$display-font-style: null !default;
+$display-font-weight: 300 !default;
+$display-line-height: $headings-line-height !default;
+// scss-docs-end display-headings
+
+// scss-docs-start type-variables
+$lead-font-size: $font-size-base * 1.25 !default;
+$lead-font-weight: 300 !default;
+
+$small-font-size: .875em !default;
+
+$sub-sup-font-size: .75em !default;
+
+$text-muted: $gray-600 !default;
+
+$initialism-font-size: $small-font-size !default;
+
+$blockquote-margin-y: $spacer !default;
+$blockquote-font-size: $font-size-base * 1.25 !default;
+$blockquote-footer-color: $gray-600 !default;
+$blockquote-footer-font-size: $small-font-size !default;
+
+$hr-margin-y: $spacer !default;
+$hr-color: inherit !default;
+
+// fusv-disable
+$hr-bg-color: null !default; // Deprecated in v5.2.0
+$hr-height: null !default; // Deprecated in v5.2.0
+// fusv-enable
+
+$hr-border-color: null !default; // Allows for inherited colors
+$hr-border-width: $border-width !default;
+$hr-opacity: .25 !default;
+
+$legend-margin-bottom: .5rem !default;
+$legend-font-size: 1.5rem !default;
+$legend-font-weight: null !default;
+
+$dt-font-weight: $font-weight-bold !default;
+
+$list-inline-padding: .5rem !default;
+
+$mark-padding: .1875em !default;
+$mark-bg: $yellow-100 !default;
+// scss-docs-end type-variables
+
+
+// Tables
+//
+// Customizes the `.table` component with basic values, each used across all table variations.
+
+// scss-docs-start table-variables
+$table-cell-padding-y: .5rem !default;
+$table-cell-padding-x: .5rem !default;
+$table-cell-padding-y-sm: .25rem !default;
+$table-cell-padding-x-sm: .25rem !default;
+
+$table-cell-vertical-align: top !default;
+
+$table-color: var(--#{$prefix}body-color) !default;
+$table-bg: transparent !default;
+$table-accent-bg: transparent !default;
+
+$table-th-font-weight: null !default;
+
+$table-striped-color: $table-color !default;
+$table-striped-bg-factor: .05 !default;
+$table-striped-bg: rgba($black, $table-striped-bg-factor) !default;
+
+$table-active-color: $table-color !default;
+$table-active-bg-factor: .1 !default;
+$table-active-bg: rgba($black, $table-active-bg-factor) !default;
+
+$table-hover-color: $table-color !default;
+$table-hover-bg-factor: .075 !default;
+$table-hover-bg: rgba($black, $table-hover-bg-factor) !default;
+
+$table-border-factor: .1 !default;
+$table-border-width: $border-width !default;
+$table-border-color: var(--#{$prefix}border-color) !default;
+
+$table-striped-order: odd !default;
+$table-striped-columns-order: even !default;
+
+$table-group-separator-color: currentcolor !default;
+
+$table-caption-color: $text-muted !default;
+
+$table-bg-scale: -80% !default;
+// scss-docs-end table-variables
+
+// scss-docs-start table-loop
+$table-variants: (
+ "primary": shift-color($primary, $table-bg-scale),
+ "secondary": shift-color($secondary, $table-bg-scale),
+ "success": shift-color($success, $table-bg-scale),
+ "info": shift-color($info, $table-bg-scale),
+ "warning": shift-color($warning, $table-bg-scale),
+ "danger": shift-color($danger, $table-bg-scale),
+ "light": $light,
+ "dark": $dark,
+) !default;
+// scss-docs-end table-loop
+
+
+// Buttons + Forms
+//
+// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.
+
+// scss-docs-start input-btn-variables
+$input-btn-padding-y: .375rem !default;
+$input-btn-padding-x: .75rem !default;
+$input-btn-font-family: null !default;
+$input-btn-font-size: $font-size-base !default;
+$input-btn-line-height: $line-height-base !default;
+
+$input-btn-focus-width: .25rem !default;
+$input-btn-focus-color-opacity: .25 !default;
+$input-btn-focus-color: rgba($component-active-bg, $input-btn-focus-color-opacity) !default;
+$input-btn-focus-blur: 0 !default;
+$input-btn-focus-box-shadow: 0 0 $input-btn-focus-blur $input-btn-focus-width $input-btn-focus-color !default;
+
+$input-btn-padding-y-sm: .25rem !default;
+$input-btn-padding-x-sm: .5rem !default;
+$input-btn-font-size-sm: $font-size-sm !default;
+
+$input-btn-padding-y-lg: .5rem !default;
+$input-btn-padding-x-lg: 1rem !default;
+$input-btn-font-size-lg: $font-size-lg !default;
+
+$input-btn-border-width: $border-width !default;
+// scss-docs-end input-btn-variables
+
+
+// Buttons
+//
+// For each of Bootstrap's buttons, define text, background, and border color.
+
+// scss-docs-start btn-variables
+$btn-padding-y: $input-btn-padding-y !default;
+$btn-padding-x: $input-btn-padding-x !default;
+$btn-font-family: $input-btn-font-family !default;
+$btn-font-size: $input-btn-font-size !default;
+$btn-line-height: $input-btn-line-height !default;
+$btn-white-space: null !default; // Set to `nowrap` to prevent text wrapping
+
+$btn-padding-y-sm: $input-btn-padding-y-sm !default;
+$btn-padding-x-sm: $input-btn-padding-x-sm !default;
+$btn-font-size-sm: $input-btn-font-size-sm !default;
+
+$btn-padding-y-lg: $input-btn-padding-y-lg !default;
+$btn-padding-x-lg: $input-btn-padding-x-lg !default;
+$btn-font-size-lg: $input-btn-font-size-lg !default;
+
+$btn-border-width: $input-btn-border-width !default;
+
+$btn-font-weight: $font-weight-normal !default;
+$btn-box-shadow: inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;
+$btn-focus-width: $input-btn-focus-width !default;
+$btn-focus-box-shadow: $input-btn-focus-box-shadow !default;
+$btn-disabled-opacity: .65 !default;
+$btn-active-box-shadow: inset 0 3px 5px rgba($black, .125) !default;
+
+$btn-link-color: var(--#{$prefix}link-color) !default;
+$btn-link-hover-color: var(--#{$prefix}link-hover-color) !default;
+$btn-link-disabled-color: $gray-600 !default;
+
+// Allows for customizing button radius independently from global border radius
+$btn-border-radius: $border-radius !default;
+$btn-border-radius-sm: $border-radius-sm !default;
+$btn-border-radius-lg: $border-radius-lg !default;
+
+$btn-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;
+
+$btn-hover-bg-shade-amount: 15% !default;
+$btn-hover-bg-tint-amount: 15% !default;
+$btn-hover-border-shade-amount: 20% !default;
+$btn-hover-border-tint-amount: 10% !default;
+$btn-active-bg-shade-amount: 20% !default;
+$btn-active-bg-tint-amount: 20% !default;
+$btn-active-border-shade-amount: 25% !default;
+$btn-active-border-tint-amount: 10% !default;
+// scss-docs-end btn-variables
+
+
+// Forms
+
+// scss-docs-start form-text-variables
+$form-text-margin-top: .25rem !default;
+$form-text-font-size: $small-font-size !default;
+$form-text-font-style: null !default;
+$form-text-font-weight: null !default;
+$form-text-color: $text-muted !default;
+// scss-docs-end form-text-variables
+
+// scss-docs-start form-label-variables
+$form-label-margin-bottom: .5rem !default;
+$form-label-font-size: null !default;
+$form-label-font-style: null !default;
+$form-label-font-weight: null !default;
+$form-label-color: null !default;
+// scss-docs-end form-label-variables
+
+// scss-docs-start form-input-variables
+$input-padding-y: $input-btn-padding-y !default;
+$input-padding-x: $input-btn-padding-x !default;
+$input-font-family: $input-btn-font-family !default;
+$input-font-size: $input-btn-font-size !default;
+$input-font-weight: $font-weight-base !default;
+$input-line-height: $input-btn-line-height !default;
+
+$input-padding-y-sm: $input-btn-padding-y-sm !default;
+$input-padding-x-sm: $input-btn-padding-x-sm !default;
+$input-font-size-sm: $input-btn-font-size-sm !default;
+
+$input-padding-y-lg: $input-btn-padding-y-lg !default;
+$input-padding-x-lg: $input-btn-padding-x-lg !default;
+$input-font-size-lg: $input-btn-font-size-lg !default;
+
+$input-bg: $body-bg !default;
+$input-disabled-color: null !default;
+$input-disabled-bg: $gray-200 !default;
+$input-disabled-border-color: null !default;
+
+$input-color: $body-color !default;
+$input-border-color: $gray-400 !default;
+$input-border-width: $input-btn-border-width !default;
+$input-box-shadow: $box-shadow-inset !default;
+
+$input-border-radius: $border-radius !default;
+$input-border-radius-sm: $border-radius-sm !default;
+$input-border-radius-lg: $border-radius-lg !default;
+
+$input-focus-bg: $input-bg !default;
+$input-focus-border-color: tint-color($component-active-bg, 50%) !default;
+$input-focus-color: $input-color !default;
+$input-focus-width: $input-btn-focus-width !default;
+$input-focus-box-shadow: $input-btn-focus-box-shadow !default;
+
+$input-placeholder-color: $gray-600 !default;
+$input-plaintext-color: $body-color !default;
+
+$input-height-border: $input-border-width * 2 !default;
+
+$input-height-inner: add($input-line-height * 1em, $input-padding-y * 2) !default;
+$input-height-inner-half: add($input-line-height * .5em, $input-padding-y) !default;
+$input-height-inner-quarter: add($input-line-height * .25em, $input-padding-y * .5) !default;
+
+$input-height: add($input-line-height * 1em, add($input-padding-y * 2, $input-height-border, false)) !default;
+$input-height-sm: add($input-line-height * 1em, add($input-padding-y-sm * 2, $input-height-border, false)) !default;
+$input-height-lg: add($input-line-height * 1em, add($input-padding-y-lg * 2, $input-height-border, false)) !default;
+
+$input-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;
+
+$form-color-width: 3rem !default;
+// scss-docs-end form-input-variables
+
+// scss-docs-start form-check-variables
+$form-check-input-width: 1em !default;
+$form-check-min-height: $font-size-base * $line-height-base !default;
+$form-check-padding-start: $form-check-input-width + .5em !default;
+$form-check-margin-bottom: .125rem !default;
+$form-check-label-color: null !default;
+$form-check-label-cursor: null !default;
+$form-check-transition: null !default;
+
+$form-check-input-active-filter: brightness(90%) !default;
+
+$form-check-input-bg: $input-bg !default;
+$form-check-input-border: 1px solid rgba($black, .25) !default;
+$form-check-input-border-radius: .25em !default;
+$form-check-radio-border-radius: 50% !default;
+$form-check-input-focus-border: $input-focus-border-color !default;
+$form-check-input-focus-box-shadow: $input-btn-focus-box-shadow !default;
+
+$form-check-input-checked-color: $component-active-color !default;
+$form-check-input-checked-bg-color: $component-active-bg !default;
+$form-check-input-checked-border-color: $form-check-input-checked-bg-color !default;
+$form-check-input-checked-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$form-check-input-checked-color}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/></svg>") !default;
+$form-check-radio-checked-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='2' fill='#{$form-check-input-checked-color}'/></svg>") !default;
+
+$form-check-input-indeterminate-color: $component-active-color !default;
+$form-check-input-indeterminate-bg-color: $component-active-bg !default;
+$form-check-input-indeterminate-border-color: $form-check-input-indeterminate-bg-color !default;
+$form-check-input-indeterminate-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$form-check-input-indeterminate-color}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/></svg>") !default;
+
+$form-check-input-disabled-opacity: .5 !default;
+$form-check-label-disabled-opacity: $form-check-input-disabled-opacity !default;
+$form-check-btn-check-disabled-opacity: $btn-disabled-opacity !default;
+
+$form-check-inline-margin-end: 1rem !default;
+// scss-docs-end form-check-variables
+
+// scss-docs-start form-switch-variables
+$form-switch-color: rgba($black, .25) !default;
+$form-switch-width: 2em !default;
+$form-switch-padding-start: $form-switch-width + .5em !default;
+$form-switch-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$form-switch-color}'/></svg>") !default;
+$form-switch-border-radius: $form-switch-width !default;
+$form-switch-transition: background-position .15s ease-in-out !default;
+
+$form-switch-focus-color: $input-focus-border-color !default;
+$form-switch-focus-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$form-switch-focus-color}'/></svg>") !default;
+
+$form-switch-checked-color: $component-active-color !default;
+$form-switch-checked-bg-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#{$form-switch-checked-color}'/></svg>") !default;
+$form-switch-checked-bg-position: right center !default;
+// scss-docs-end form-switch-variables
+
+// scss-docs-start input-group-variables
+$input-group-addon-padding-y: $input-padding-y !default;
+$input-group-addon-padding-x: $input-padding-x !default;
+$input-group-addon-font-weight: $input-font-weight !default;
+$input-group-addon-color: $input-color !default;
+$input-group-addon-bg: $gray-200 !default;
+$input-group-addon-border-color: $input-border-color !default;
+// scss-docs-end input-group-variables
+
+// scss-docs-start form-select-variables
+$form-select-padding-y: $input-padding-y !default;
+$form-select-padding-x: $input-padding-x !default;
+$form-select-font-family: $input-font-family !default;
+$form-select-font-size: $input-font-size !default;
+$form-select-indicator-padding: $form-select-padding-x * 3 !default; // Extra padding for background-image
+$form-select-font-weight: $input-font-weight !default;
+$form-select-line-height: $input-line-height !default;
+$form-select-color: $input-color !default;
+$form-select-bg: $input-bg !default;
+$form-select-disabled-color: null !default;
+$form-select-disabled-bg: $gray-200 !default;
+$form-select-disabled-border-color: $input-disabled-border-color !default;
+$form-select-bg-position: right $form-select-padding-x center !default;
+$form-select-bg-size: 16px 12px !default; // In pixels because image dimensions
+$form-select-indicator-color: $gray-800 !default;
+$form-select-indicator: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='#{$form-select-indicator-color}' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/></svg>") !default;
+
+$form-select-feedback-icon-padding-end: $form-select-padding-x * 2.5 + $form-select-indicator-padding !default;
+$form-select-feedback-icon-position: center right $form-select-indicator-padding !default;
+$form-select-feedback-icon-size: $input-height-inner-half $input-height-inner-half !default;
+
+$form-select-border-width: $input-border-width !default;
+$form-select-border-color: $input-border-color !default;
+$form-select-border-radius: $input-border-radius !default;
+$form-select-box-shadow: $box-shadow-inset !default;
+
+$form-select-focus-border-color: $input-focus-border-color !default;
+$form-select-focus-width: $input-focus-width !default;
+$form-select-focus-box-shadow: 0 0 0 $form-select-focus-width $input-btn-focus-color !default;
+
+$form-select-padding-y-sm: $input-padding-y-sm !default;
+$form-select-padding-x-sm: $input-padding-x-sm !default;
+$form-select-font-size-sm: $input-font-size-sm !default;
+$form-select-border-radius-sm: $input-border-radius-sm !default;
+
+$form-select-padding-y-lg: $input-padding-y-lg !default;
+$form-select-padding-x-lg: $input-padding-x-lg !default;
+$form-select-font-size-lg: $input-font-size-lg !default;
+$form-select-border-radius-lg: $input-border-radius-lg !default;
+
+$form-select-transition: $input-transition !default;
+// scss-docs-end form-select-variables
+
+// scss-docs-start form-range-variables
+$form-range-track-width: 100% !default;
+$form-range-track-height: .5rem !default;
+$form-range-track-cursor: pointer !default;
+$form-range-track-bg: $gray-300 !default;
+$form-range-track-border-radius: 1rem !default;
+$form-range-track-box-shadow: $box-shadow-inset !default;
+
+$form-range-thumb-width: 1rem !default;
+$form-range-thumb-height: $form-range-thumb-width !default;
+$form-range-thumb-bg: $component-active-bg !default;
+$form-range-thumb-border: 0 !default;
+$form-range-thumb-border-radius: 1rem !default;
+$form-range-thumb-box-shadow: 0 .1rem .25rem rgba($black, .1) !default;
+$form-range-thumb-focus-box-shadow: 0 0 0 1px $body-bg, $input-focus-box-shadow !default;
+$form-range-thumb-focus-box-shadow-width: $input-focus-width !default; // For focus box shadow issue in Edge
+$form-range-thumb-active-bg: tint-color($component-active-bg, 70%) !default;
+$form-range-thumb-disabled-bg: $gray-500 !default;
+$form-range-thumb-transition: background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;
+// scss-docs-end form-range-variables
+
+// scss-docs-start form-file-variables
+$form-file-button-color: $input-color !default;
+$form-file-button-bg: $input-group-addon-bg !default;
+$form-file-button-hover-bg: shade-color($form-file-button-bg, 5%) !default;
+// scss-docs-end form-file-variables
+
+// scss-docs-start form-floating-variables
+$form-floating-height: add(3.5rem, $input-height-border) !default;
+$form-floating-line-height: 1.25 !default;
+$form-floating-padding-x: $input-padding-x !default;
+$form-floating-padding-y: 1rem !default;
+$form-floating-input-padding-t: 1.625rem !default;
+$form-floating-input-padding-b: .625rem !default;
+$form-floating-label-opacity: .65 !default;
+$form-floating-label-transform: scale(.85) translateY(-.5rem) translateX(.15rem) !default;
+$form-floating-transition: opacity .1s ease-in-out, transform .1s ease-in-out !default;
+// scss-docs-end form-floating-variables
+
+// Form validation
+
+// scss-docs-start form-feedback-variables
+$form-feedback-margin-top: $form-text-margin-top !default;
+$form-feedback-font-size: $form-text-font-size !default;
+$form-feedback-font-style: $form-text-font-style !default;
+$form-feedback-valid-color: $success !default;
+$form-feedback-invalid-color: $danger !default;
+
+$form-feedback-icon-valid-color: $form-feedback-valid-color !default;
+$form-feedback-icon-valid: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'><path fill='#{$form-feedback-icon-valid-color}' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/></svg>") !default;
+$form-feedback-icon-invalid-color: $form-feedback-invalid-color !default;
+$form-feedback-icon-invalid: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='#{$form-feedback-icon-invalid-color}'><circle cx='6' cy='6' r='4.5'/><path stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/><circle cx='6' cy='8.2' r='.6' fill='#{$form-feedback-icon-invalid-color}' stroke='none'/></svg>") !default;
+// scss-docs-end form-feedback-variables
+
+// scss-docs-start form-validation-states
+$form-validation-states: (
+ "valid": (
+ "color": $form-feedback-valid-color,
+ "icon": $form-feedback-icon-valid
+ ),
+ "invalid": (
+ "color": $form-feedback-invalid-color,
+ "icon": $form-feedback-icon-invalid
+ )
+) !default;
+// scss-docs-end form-validation-states
+
+// Z-index master list
+//
+// Warning: Avoid customizing these values. They're used for a bird's eye view
+// of components dependent on the z-axis and are designed to all work together.
+
+// scss-docs-start zindex-stack
+$zindex-dropdown: 1000 !default;
+$zindex-sticky: 1020 !default;
+$zindex-fixed: 1030 !default;
+$zindex-offcanvas-backdrop: 1040 !default;
+$zindex-offcanvas: 1045 !default;
+$zindex-modal-backdrop: 1050 !default;
+$zindex-modal: 1055 !default;
+$zindex-popover: 1070 !default;
+$zindex-tooltip: 1080 !default;
+$zindex-toast: 1090 !default;
+// scss-docs-end zindex-stack
+
+
+// Navs
+
+// scss-docs-start nav-variables
+$nav-link-padding-y: .5rem !default;
+$nav-link-padding-x: 1rem !default;
+$nav-link-font-size: null !default;
+$nav-link-font-weight: null !default;
+$nav-link-color: var(--#{$prefix}link-color) !default;
+$nav-link-hover-color: var(--#{$prefix}link-hover-color) !default;
+$nav-link-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out !default;
+$nav-link-disabled-color: $gray-600 !default;
+
+$nav-tabs-border-color: $gray-300 !default;
+$nav-tabs-border-width: $border-width !default;
+$nav-tabs-border-radius: $border-radius !default;
+$nav-tabs-link-hover-border-color: $gray-200 $gray-200 $nav-tabs-border-color !default;
+$nav-tabs-link-active-color: $gray-700 !default;
+$nav-tabs-link-active-bg: $body-bg !default;
+$nav-tabs-link-active-border-color: $gray-300 $gray-300 $nav-tabs-link-active-bg !default;
+
+$nav-pills-border-radius: $border-radius !default;
+$nav-pills-link-active-color: $component-active-color !default;
+$nav-pills-link-active-bg: $component-active-bg !default;
+// scss-docs-end nav-variables
+
+
+// Navbar
+
+// scss-docs-start navbar-variables
+$navbar-padding-y: $spacer * .5 !default;
+$navbar-padding-x: null !default;
+
+$navbar-nav-link-padding-x: .5rem !default;
+
+$navbar-brand-font-size: $font-size-lg !default;
+// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link
+$nav-link-height: $font-size-base * $line-height-base + $nav-link-padding-y * 2 !default;
+$navbar-brand-height: $navbar-brand-font-size * $line-height-base !default;
+$navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) * .5 !default;
+$navbar-brand-margin-end: 1rem !default;
+
+$navbar-toggler-padding-y: .25rem !default;
+$navbar-toggler-padding-x: .75rem !default;
+$navbar-toggler-font-size: $font-size-lg !default;
+$navbar-toggler-border-radius: $btn-border-radius !default;
+$navbar-toggler-focus-width: $btn-focus-width !default;
+$navbar-toggler-transition: box-shadow .15s ease-in-out !default;
+
+$navbar-light-color: rgba($black, .55) !default;
+$navbar-light-hover-color: rgba($black, .7) !default;
+$navbar-light-active-color: rgba($black, .9) !default;
+$navbar-light-disabled-color: rgba($black, .3) !default;
+$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>") !default;
+$navbar-light-toggler-border-color: rgba($black, .1) !default;
+$navbar-light-brand-color: $navbar-light-active-color !default;
+$navbar-light-brand-hover-color: $navbar-light-active-color !default;
+// scss-docs-end navbar-variables
+
+// scss-docs-start navbar-dark-variables
+$navbar-dark-color: rgba($white, .55) !default;
+$navbar-dark-hover-color: rgba($white, .75) !default;
+$navbar-dark-active-color: $white !default;
+$navbar-dark-disabled-color: rgba($white, .25) !default;
+$navbar-dark-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-dark-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>") !default;
+$navbar-dark-toggler-border-color: rgba($white, .1) !default;
+$navbar-dark-brand-color: $navbar-dark-active-color !default;
+$navbar-dark-brand-hover-color: $navbar-dark-active-color !default;
+// scss-docs-end navbar-dark-variables
+
+
+// Dropdowns
+//
+// Dropdown menu container and contents.
+
+// scss-docs-start dropdown-variables
+$dropdown-min-width: 10rem !default;
+$dropdown-padding-x: 0 !default;
+$dropdown-padding-y: .5rem !default;
+$dropdown-spacer: .125rem !default;
+$dropdown-font-size: $font-size-base !default;
+$dropdown-color: $body-color !default;
+$dropdown-bg: $white !default;
+$dropdown-border-color: var(--#{$prefix}border-color-translucent) !default;
+$dropdown-border-radius: $border-radius !default;
+$dropdown-border-width: $border-width !default;
+$dropdown-inner-border-radius: subtract($dropdown-border-radius, $dropdown-border-width) !default;
+$dropdown-divider-bg: $dropdown-border-color !default;
+$dropdown-divider-margin-y: $spacer * .5 !default;
+$dropdown-box-shadow: $box-shadow !default;
+
+$dropdown-link-color: $gray-900 !default;
+$dropdown-link-hover-color: shade-color($dropdown-link-color, 10%) !default;
+$dropdown-link-hover-bg: $gray-200 !default;
+
+$dropdown-link-active-color: $component-active-color !default;
+$dropdown-link-active-bg: $component-active-bg !default;
+
+$dropdown-link-disabled-color: $gray-500 !default;
+
+$dropdown-item-padding-y: $spacer * .25 !default;
+$dropdown-item-padding-x: $spacer !default;
+
+$dropdown-header-color: $gray-600 !default;
+$dropdown-header-padding-x: $dropdown-item-padding-x !default;
+$dropdown-header-padding-y: $dropdown-padding-y !default;
+// fusv-disable
+$dropdown-header-padding: $dropdown-header-padding-y $dropdown-header-padding-x !default; // Deprecated in v5.2.0
+// fusv-enable
+// scss-docs-end dropdown-variables
+
+// scss-docs-start dropdown-dark-variables
+$dropdown-dark-color: $gray-300 !default;
+$dropdown-dark-bg: $gray-800 !default;
+$dropdown-dark-border-color: $dropdown-border-color !default;
+$dropdown-dark-divider-bg: $dropdown-divider-bg !default;
+$dropdown-dark-box-shadow: null !default;
+$dropdown-dark-link-color: $dropdown-dark-color !default;
+$dropdown-dark-link-hover-color: $white !default;
+$dropdown-dark-link-hover-bg: rgba($white, .15) !default;
+$dropdown-dark-link-active-color: $dropdown-link-active-color !default;
+$dropdown-dark-link-active-bg: $dropdown-link-active-bg !default;
+$dropdown-dark-link-disabled-color: $gray-500 !default;
+$dropdown-dark-header-color: $gray-500 !default;
+// scss-docs-end dropdown-dark-variables
+
+
+// Pagination
+
+// scss-docs-start pagination-variables
+$pagination-padding-y: .375rem !default;
+$pagination-padding-x: .75rem !default;
+$pagination-padding-y-sm: .25rem !default;
+$pagination-padding-x-sm: .5rem !default;
+$pagination-padding-y-lg: .75rem !default;
+$pagination-padding-x-lg: 1.5rem !default;
+
+$pagination-font-size: $font-size-base !default;
+
+$pagination-color: var(--#{$prefix}link-color) !default;
+$pagination-bg: $white !default;
+$pagination-border-radius: $border-radius !default;
+$pagination-border-width: $border-width !default;
+$pagination-margin-start: ($pagination-border-width * -1) !default;
+$pagination-border-color: $gray-300 !default;
+
+$pagination-focus-color: var(--#{$prefix}link-hover-color) !default;
+$pagination-focus-bg: $gray-200 !default;
+$pagination-focus-box-shadow: $input-btn-focus-box-shadow !default;
+$pagination-focus-outline: 0 !default;
+
+$pagination-hover-color: var(--#{$prefix}link-hover-color) !default;
+$pagination-hover-bg: $gray-200 !default;
+$pagination-hover-border-color: $gray-300 !default;
+
+$pagination-active-color: $component-active-color !default;
+$pagination-active-bg: $component-active-bg !default;
+$pagination-active-border-color: $pagination-active-bg !default;
+
+$pagination-disabled-color: $gray-600 !default;
+$pagination-disabled-bg: $white !default;
+$pagination-disabled-border-color: $gray-300 !default;
+
+$pagination-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;
+
+$pagination-border-radius-sm: $border-radius-sm !default;
+$pagination-border-radius-lg: $border-radius-lg !default;
+// scss-docs-end pagination-variables
+
+
+// Placeholders
+
+// scss-docs-start placeholders
+$placeholder-opacity-max: .5 !default;
+$placeholder-opacity-min: .2 !default;
+// scss-docs-end placeholders
+
+// Cards
+
+// scss-docs-start card-variables
+$card-spacer-y: $spacer !default;
+$card-spacer-x: $spacer !default;
+$card-title-spacer-y: $spacer * .5 !default;
+$card-border-width: $border-width !default;
+$card-border-color: var(--#{$prefix}border-color-translucent) !default;
+$card-border-radius: $border-radius !default;
+$card-box-shadow: null !default;
+$card-inner-border-radius: subtract($card-border-radius, $card-border-width) !default;
+$card-cap-padding-y: $card-spacer-y * .5 !default;
+$card-cap-padding-x: $card-spacer-x !default;
+$card-cap-bg: rgba($black, .03) !default;
+$card-cap-color: null !default;
+$card-height: null !default;
+$card-color: null !default;
+$card-bg: $white !default;
+$card-img-overlay-padding: $spacer !default;
+$card-group-margin: $grid-gutter-width * .5 !default;
+// scss-docs-end card-variables
+
+// Accordion
+
+// scss-docs-start accordion-variables
+$accordion-padding-y: 1rem !default;
+$accordion-padding-x: 1.25rem !default;
+$accordion-color: var(--#{$prefix}body-color) !default;
+$accordion-bg: $body-bg !default;
+$accordion-border-width: $border-width !default;
+$accordion-border-color: var(--#{$prefix}border-color) !default;
+$accordion-border-radius: $border-radius !default;
+$accordion-inner-border-radius: subtract($accordion-border-radius, $accordion-border-width) !default;
+
+$accordion-body-padding-y: $accordion-padding-y !default;
+$accordion-body-padding-x: $accordion-padding-x !default;
+
+$accordion-button-padding-y: $accordion-padding-y !default;
+$accordion-button-padding-x: $accordion-padding-x !default;
+$accordion-button-color: $accordion-color !default;
+$accordion-button-bg: var(--#{$prefix}accordion-bg) !default;
+$accordion-transition: $btn-transition, border-radius .15s ease !default;
+$accordion-button-active-bg: tint-color($component-active-bg, 90%) !default;
+$accordion-button-active-color: shade-color($primary, 10%) !default;
+
+$accordion-button-focus-border-color: $input-focus-border-color !default;
+$accordion-button-focus-box-shadow: $btn-focus-box-shadow !default;
+
+$accordion-icon-width: 1.25rem !default;
+$accordion-icon-color: $accordion-button-color !default;
+$accordion-icon-active-color: $accordion-button-active-color !default;
+$accordion-icon-transition: transform .2s ease-in-out !default;
+$accordion-icon-transform: rotate(-180deg) !default;
+
+$accordion-button-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$accordion-icon-color}'><path fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/></svg>") !default;
+$accordion-button-active-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$accordion-icon-active-color}'><path fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/></svg>") !default;
+// scss-docs-end accordion-variables
+
+// Tooltips
+
+// scss-docs-start tooltip-variables
+$tooltip-font-size: $font-size-sm !default;
+$tooltip-max-width: 200px !default;
+$tooltip-color: $white !default;
+$tooltip-bg: $black !default;
+$tooltip-border-radius: $border-radius !default;
+$tooltip-opacity: .9 !default;
+$tooltip-padding-y: $spacer * .25 !default;
+$tooltip-padding-x: $spacer * .5 !default;
+$tooltip-margin: null !default; // TODO: remove this in v6
+
+$tooltip-arrow-width: .8rem !default;
+$tooltip-arrow-height: .4rem !default;
+// fusv-disable
+$tooltip-arrow-color: null !default; // Deprecated in Bootstrap 5.2.0 for CSS variables
+// fusv-enable
+// scss-docs-end tooltip-variables
+
+// Form tooltips must come after regular tooltips
+// scss-docs-start tooltip-feedback-variables
+$form-feedback-tooltip-padding-y: $tooltip-padding-y !default;
+$form-feedback-tooltip-padding-x: $tooltip-padding-x !default;
+$form-feedback-tooltip-font-size: $tooltip-font-size !default;
+$form-feedback-tooltip-line-height: null !default;
+$form-feedback-tooltip-opacity: $tooltip-opacity !default;
+$form-feedback-tooltip-border-radius: $tooltip-border-radius !default;
+// scss-docs-end tooltip-feedback-variables
+
+
+// Popovers
+
+// scss-docs-start popover-variables
+$popover-font-size: $font-size-sm !default;
+$popover-bg: $white !default;
+$popover-max-width: 276px !default;
+$popover-border-width: $border-width !default;
+$popover-border-color: var(--#{$prefix}border-color-translucent) !default;
+$popover-border-radius: $border-radius-lg !default;
+$popover-inner-border-radius: subtract($popover-border-radius, $popover-border-width) !default;
+$popover-box-shadow: $box-shadow !default;
+
+$popover-header-font-size: $font-size-base !default;
+$popover-header-bg: shade-color($popover-bg, 6%) !default;
+$popover-header-color: $headings-color !default;
+$popover-header-padding-y: .5rem !default;
+$popover-header-padding-x: $spacer !default;
+
+$popover-body-color: $body-color !default;
+$popover-body-padding-y: $spacer !default;
+$popover-body-padding-x: $spacer !default;
+
+$popover-arrow-width: 1rem !default;
+$popover-arrow-height: .5rem !default;
+// scss-docs-end popover-variables
+
+// fusv-disable
+// Deprecated in Bootstrap 5.2.0 for CSS variables
+$popover-arrow-color: $popover-bg !default;
+$popover-arrow-outer-color: var(--#{$prefix}border-color-translucent) !default;
+// fusv-enable
+
+
+// Toasts
+
+// scss-docs-start toast-variables
+$toast-max-width: 350px !default;
+$toast-padding-x: .75rem !default;
+$toast-padding-y: .5rem !default;
+$toast-font-size: .875rem !default;
+$toast-color: null !default;
+$toast-background-color: rgba($white, .85) !default;
+$toast-border-width: $border-width !default;
+$toast-border-color: var(--#{$prefix}border-color-translucent) !default;
+$toast-border-radius: $border-radius !default;
+$toast-box-shadow: $box-shadow !default;
+$toast-spacing: $container-padding-x !default;
+
+$toast-header-color: $gray-600 !default;
+$toast-header-background-color: rgba($white, .85) !default;
+$toast-header-border-color: rgba($black, .05) !default;
+// scss-docs-end toast-variables
+
+
+// Badges
+
+// scss-docs-start badge-variables
+$badge-font-size: .75em !default;
+$badge-font-weight: $font-weight-bold !default;
+$badge-color: $white !default;
+$badge-padding-y: .35em !default;
+$badge-padding-x: .65em !default;
+$badge-border-radius: $border-radius !default;
+// scss-docs-end badge-variables
+
+
+// Modals
+
+// scss-docs-start modal-variables
+$modal-inner-padding: $spacer !default;
+
+$modal-footer-margin-between: .5rem !default;
+
+$modal-dialog-margin: .5rem !default;
+$modal-dialog-margin-y-sm-up: 1.75rem !default;
+
+$modal-title-line-height: $line-height-base !default;
+
+$modal-content-color: null !default;
+$modal-content-bg: $white !default;
+$modal-content-border-color: var(--#{$prefix}border-color-translucent) !default;
+$modal-content-border-width: $border-width !default;
+$modal-content-border-radius: $border-radius-lg !default;
+$modal-content-inner-border-radius: subtract($modal-content-border-radius, $modal-content-border-width) !default;
+$modal-content-box-shadow-xs: $box-shadow-sm !default;
+$modal-content-box-shadow-sm-up: $box-shadow !default;
+
+$modal-backdrop-bg: $black !default;
+$modal-backdrop-opacity: .5 !default;
+
+$modal-header-border-color: var(--#{$prefix}border-color) !default;
+$modal-header-border-width: $modal-content-border-width !default;
+$modal-header-padding-y: $modal-inner-padding !default;
+$modal-header-padding-x: $modal-inner-padding !default;
+$modal-header-padding: $modal-header-padding-y $modal-header-padding-x !default; // Keep this for backwards compatibility
+
+$modal-footer-bg: null !default;
+$modal-footer-border-color: $modal-header-border-color !default;
+$modal-footer-border-width: $modal-header-border-width !default;
+
+$modal-sm: 300px !default;
+$modal-md: 500px !default;
+$modal-lg: 800px !default;
+$modal-xl: 1140px !default;
+
+$modal-fade-transform: translate(0, -50px) !default;
+$modal-show-transform: none !default;
+$modal-transition: transform .3s ease-out !default;
+$modal-scale-transform: scale(1.02) !default;
+// scss-docs-end modal-variables
+
+
+// Alerts
+//
+// Define alert colors, border radius, and padding.
+
+// scss-docs-start alert-variables
+$alert-padding-y: $spacer !default;
+$alert-padding-x: $spacer !default;
+$alert-margin-bottom: 1rem !default;
+$alert-border-radius: $border-radius !default;
+$alert-link-font-weight: $font-weight-bold !default;
+$alert-border-width: $border-width !default;
+$alert-bg-scale: -80% !default;
+$alert-border-scale: -70% !default;
+$alert-color-scale: 40% !default;
+$alert-dismissible-padding-r: $alert-padding-x * 3 !default; // 3x covers width of x plus default padding on either side
+// scss-docs-end alert-variables
+
+
+// Progress bars
+
+// scss-docs-start progress-variables
+$progress-height: 1rem !default;
+$progress-font-size: $font-size-base * .75 !default;
+$progress-bg: $gray-200 !default;
+$progress-border-radius: $border-radius !default;
+$progress-box-shadow: $box-shadow-inset !default;
+$progress-bar-color: $white !default;
+$progress-bar-bg: $primary !default;
+$progress-bar-animation-timing: 1s linear infinite !default;
+$progress-bar-transition: width .6s ease !default;
+// scss-docs-end progress-variables
+
+
+// List group
+
+// scss-docs-start list-group-variables
+$list-group-color: $gray-900 !default;
+$list-group-bg: $white !default;
+$list-group-border-color: rgba($black, .125) !default;
+$list-group-border-width: $border-width !default;
+$list-group-border-radius: $border-radius !default;
+
+$list-group-item-padding-y: $spacer * .5 !default;
+$list-group-item-padding-x: $spacer !default;
+$list-group-item-bg-scale: -80% !default;
+$list-group-item-color-scale: 40% !default;
+
+$list-group-hover-bg: $gray-100 !default;
+$list-group-active-color: $component-active-color !default;
+$list-group-active-bg: $component-active-bg !default;
+$list-group-active-border-color: $list-group-active-bg !default;
+
+$list-group-disabled-color: $gray-600 !default;
+$list-group-disabled-bg: $list-group-bg !default;
+
+$list-group-action-color: $gray-700 !default;
+$list-group-action-hover-color: $list-group-action-color !default;
+
+$list-group-action-active-color: $body-color !default;
+$list-group-action-active-bg: $gray-200 !default;
+// scss-docs-end list-group-variables
+
+
+// Image thumbnails
+
+// scss-docs-start thumbnail-variables
+$thumbnail-padding: .25rem !default;
+$thumbnail-bg: $body-bg !default;
+$thumbnail-border-width: $border-width !default;
+$thumbnail-border-color: var(--#{$prefix}border-color) !default;
+$thumbnail-border-radius: $border-radius !default;
+$thumbnail-box-shadow: $box-shadow-sm !default;
+// scss-docs-end thumbnail-variables
+
+
+// Figures
+
+// scss-docs-start figure-variables
+$figure-caption-font-size: $small-font-size !default;
+$figure-caption-color: $gray-600 !default;
+// scss-docs-end figure-variables
+
+
+// Breadcrumbs
+
+// scss-docs-start breadcrumb-variables
+$breadcrumb-font-size: null !default;
+$breadcrumb-padding-y: 0 !default;
+$breadcrumb-padding-x: 0 !default;
+$breadcrumb-item-padding-x: .5rem !default;
+$breadcrumb-margin-bottom: 1rem !default;
+$breadcrumb-bg: null !default;
+$breadcrumb-divider-color: $gray-600 !default;
+$breadcrumb-active-color: $gray-600 !default;
+$breadcrumb-divider: quote("/") !default;
+$breadcrumb-divider-flipped: $breadcrumb-divider !default;
+$breadcrumb-border-radius: null !default;
+// scss-docs-end breadcrumb-variables
+
+// Carousel
+
+// scss-docs-start carousel-variables
+$carousel-control-color: $white !default;
+$carousel-control-width: 15% !default;
+$carousel-control-opacity: .5 !default;
+$carousel-control-hover-opacity: .9 !default;
+$carousel-control-transition: opacity .15s ease !default;
+
+$carousel-indicator-width: 30px !default;
+$carousel-indicator-height: 3px !default;
+$carousel-indicator-hit-area-height: 10px !default;
+$carousel-indicator-spacer: 3px !default;
+$carousel-indicator-opacity: .5 !default;
+$carousel-indicator-active-bg: $white !default;
+$carousel-indicator-active-opacity: 1 !default;
+$carousel-indicator-transition: opacity .6s ease !default;
+
+$carousel-caption-width: 70% !default;
+$carousel-caption-color: $white !default;
+$carousel-caption-padding-y: 1.25rem !default;
+$carousel-caption-spacer: 1.25rem !default;
+
+$carousel-control-icon-width: 2rem !default;
+
+$carousel-control-prev-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$carousel-control-color}'><path d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/></svg>") !default;
+$carousel-control-next-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$carousel-control-color}'><path d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/></svg>") !default;
+
+$carousel-transition-duration: .6s !default;
+$carousel-transition: transform $carousel-transition-duration ease-in-out !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`)
+// scss-docs-end carousel-variables
+
+// scss-docs-start carousel-dark-variables
+$carousel-dark-indicator-active-bg: $black !default;
+$carousel-dark-caption-color: $black !default;
+$carousel-dark-control-icon-filter: invert(1) grayscale(100) !default;
+// scss-docs-end carousel-dark-variables
+
+
+// Spinners
+
+// scss-docs-start spinner-variables
+$spinner-width: 2rem !default;
+$spinner-height: $spinner-width !default;
+$spinner-vertical-align: -.125em !default;
+$spinner-border-width: .25em !default;
+$spinner-animation-speed: .75s !default;
+
+$spinner-width-sm: 1rem !default;
+$spinner-height-sm: $spinner-width-sm !default;
+$spinner-border-width-sm: .2em !default;
+// scss-docs-end spinner-variables
+
+
+// Close
+
+// scss-docs-start close-variables
+$btn-close-width: 1em !default;
+$btn-close-height: $btn-close-width !default;
+$btn-close-padding-x: .25em !default;
+$btn-close-padding-y: $btn-close-padding-x !default;
+$btn-close-color: $black !default;
+$btn-close-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$btn-close-color}'><path d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/></svg>") !default;
+$btn-close-focus-shadow: $input-btn-focus-box-shadow !default;
+$btn-close-opacity: .5 !default;
+$btn-close-hover-opacity: .75 !default;
+$btn-close-focus-opacity: 1 !default;
+$btn-close-disabled-opacity: .25 !default;
+$btn-close-white-filter: invert(1) grayscale(100%) brightness(200%) !default;
+// scss-docs-end close-variables
+
+
+// Offcanvas
+
+// scss-docs-start offcanvas-variables
+$offcanvas-padding-y: $modal-inner-padding !default;
+$offcanvas-padding-x: $modal-inner-padding !default;
+$offcanvas-horizontal-width: 400px !default;
+$offcanvas-vertical-height: 30vh !default;
+$offcanvas-transition-duration: .3s !default;
+$offcanvas-border-color: $modal-content-border-color !default;
+$offcanvas-border-width: $modal-content-border-width !default;
+$offcanvas-title-line-height: $modal-title-line-height !default;
+$offcanvas-bg-color: $modal-content-bg !default;
+$offcanvas-color: $modal-content-color !default;
+$offcanvas-box-shadow: $modal-content-box-shadow-xs !default;
+$offcanvas-backdrop-bg: $modal-backdrop-bg !default;
+$offcanvas-backdrop-opacity: $modal-backdrop-opacity !default;
+// scss-docs-end offcanvas-variables
+
+// Code
+
+$code-font-size: $small-font-size !default;
+$code-color: $pink !default;
+
+$kbd-padding-y: .1875rem !default;
+$kbd-padding-x: .375rem !default;
+$kbd-font-size: $code-font-size !default;
+$kbd-color: var(--#{$prefix}body-bg) !default;
+$kbd-bg: var(--#{$prefix}body-color) !default;
+$nested-kbd-font-weight: null !default; // Deprecated in v5.2.0, removing in v6
+
+$pre-color: null !default;
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-grid.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-grid.scss
new file mode 100644
index 0000000000..1c4cdd1a0f
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-grid.scss
@@ -0,0 +1,64 @@
+@import "mixins/banner";
+@include bsBanner(Grid);
+
+$include-column-box-sizing: true !default;
+
+@import "functions";
+@import "variables";
+@import "maps";
+
+@import "mixins/lists";
+@import "mixins/breakpoints";
+@import "mixins/container";
+@import "mixins/grid";
+@import "mixins/utilities";
+
+@import "vendor/rfs";
+
+@import "root";
+
+@import "containers";
+@import "grid";
+
+@import "utilities";
+// Only use the utilities we need
+// stylelint-disable-next-line scss/dollar-variable-default
+$utilities: map-get-multiple(
+ $utilities,
+ (
+ "display",
+ "order",
+ "flex",
+ "flex-direction",
+ "flex-grow",
+ "flex-shrink",
+ "flex-wrap",
+ "justify-content",
+ "align-items",
+ "align-content",
+ "align-self",
+ "margin",
+ "margin-x",
+ "margin-y",
+ "margin-top",
+ "margin-end",
+ "margin-bottom",
+ "margin-start",
+ "negative-margin",
+ "negative-margin-x",
+ "negative-margin-y",
+ "negative-margin-top",
+ "negative-margin-end",
+ "negative-margin-bottom",
+ "negative-margin-start",
+ "padding",
+ "padding-x",
+ "padding-y",
+ "padding-top",
+ "padding-end",
+ "padding-bottom",
+ "padding-start",
+ )
+);
+
+@import "utilities/api";
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-reboot.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-reboot.scss
new file mode 100644
index 0000000000..af52745943
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-reboot.scss
@@ -0,0 +1,9 @@
+@import "mixins/banner";
+@include bsBanner(Reboot);
+
+@import "functions";
+@import "variables";
+@import "maps";
+@import "mixins";
+@import "root";
+@import "reboot";
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-utilities.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-utilities.scss
new file mode 100644
index 0000000000..c940676ac1
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap-utilities.scss
@@ -0,0 +1,15 @@
+@import "mixins/banner";
+@include bsBanner(Utilities);
+
+// Configuration
+@import "functions";
+@import "variables";
+@import "maps";
+@import "mixins";
+@import "utilities";
+
+// Helpers
+@import "helpers";
+
+// Utilities
+@import "utilities/api";
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap.scss
new file mode 100644
index 0000000000..8f8296deff
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/bootstrap.scss
@@ -0,0 +1,51 @@
+@import "mixins/banner";
+@include bsBanner("");
+
+
+// scss-docs-start import-stack
+// Configuration
+@import "functions";
+@import "variables";
+@import "maps";
+@import "mixins";
+@import "utilities";
+
+// Layout & components
+@import "root";
+@import "reboot";
+@import "type";
+@import "images";
+@import "containers";
+@import "grid";
+@import "tables";
+@import "forms";
+@import "buttons";
+@import "transitions";
+@import "dropdown";
+@import "button-group";
+@import "nav";
+@import "navbar";
+@import "card";
+@import "accordion";
+@import "breadcrumb";
+@import "pagination";
+@import "badge";
+@import "alert";
+@import "progress";
+@import "list-group";
+@import "close";
+@import "toasts";
+@import "modal";
+@import "tooltip";
+@import "popover";
+@import "carousel";
+@import "spinners";
+@import "offcanvas";
+@import "placeholders";
+
+// Helpers
+@import "helpers";
+
+// Utilities
+@import "utilities/api";
+// scss-docs-end import-stack
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_floating-labels.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_floating-labels.scss
new file mode 100644
index 0000000000..6e5c9a75f5
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_floating-labels.scss
@@ -0,0 +1,75 @@
+.form-floating {
+ position: relative;
+
+ > .form-control,
+ > .form-control-plaintext,
+ > .form-select {
+ height: $form-floating-height;
+ line-height: $form-floating-line-height;
+ }
+
+ > label {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%; // allow textareas
+ padding: $form-floating-padding-y $form-floating-padding-x;
+ overflow: hidden;
+ text-align: start;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ pointer-events: none;
+ border: $input-border-width solid transparent; // Required for aligning label's text with the input as it affects inner box model
+ transform-origin: 0 0;
+ @include transition($form-floating-transition);
+ }
+
+ > .form-control,
+ > .form-control-plaintext {
+ padding: $form-floating-padding-y $form-floating-padding-x;
+
+ &::placeholder {
+ color: transparent;
+ }
+
+ &:focus,
+ &:not(:placeholder-shown) {
+ padding-top: $form-floating-input-padding-t;
+ padding-bottom: $form-floating-input-padding-b;
+ }
+ // Duplicated because `:-webkit-autofill` invalidates other selectors when grouped
+ &:-webkit-autofill {
+ padding-top: $form-floating-input-padding-t;
+ padding-bottom: $form-floating-input-padding-b;
+ }
+ }
+
+ > .form-select {
+ padding-top: $form-floating-input-padding-t;
+ padding-bottom: $form-floating-input-padding-b;
+ }
+
+ > .form-control:focus,
+ > .form-control:not(:placeholder-shown),
+ > .form-control-plaintext,
+ > .form-select {
+ ~ label {
+ opacity: $form-floating-label-opacity;
+ transform: $form-floating-label-transform;
+ }
+ }
+ // Duplicated because `:-webkit-autofill` invalidates other selectors when grouped
+ > .form-control:-webkit-autofill {
+ ~ label {
+ opacity: $form-floating-label-opacity;
+ transform: $form-floating-label-transform;
+ }
+ }
+
+ > .form-control-plaintext {
+ ~ label {
+ border-width: $input-border-width 0; // Required to properly position label text - as explained above
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-check.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-check.scss
new file mode 100644
index 0000000000..42a2a96073
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-check.scss
@@ -0,0 +1,175 @@
+//
+// Check/radio
+//
+
+.form-check {
+ display: block;
+ min-height: $form-check-min-height;
+ padding-left: $form-check-padding-start;
+ margin-bottom: $form-check-margin-bottom;
+
+ .form-check-input {
+ float: left;
+ margin-left: $form-check-padding-start * -1;
+ }
+}
+
+.form-check-reverse {
+ padding-right: $form-check-padding-start;
+ padding-left: 0;
+ text-align: right;
+
+ .form-check-input {
+ float: right;
+ margin-right: $form-check-padding-start * -1;
+ margin-left: 0;
+ }
+}
+
+.form-check-input {
+ width: $form-check-input-width;
+ height: $form-check-input-width;
+ margin-top: ($line-height-base - $form-check-input-width) * .5; // line-height minus check height
+ vertical-align: top;
+ background-color: $form-check-input-bg;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+ border: $form-check-input-border;
+ appearance: none;
+ print-color-adjust: exact; // Keep themed appearance for print
+ @include transition($form-check-transition);
+
+ &[type="checkbox"] {
+ @include border-radius($form-check-input-border-radius);
+ }
+
+ &[type="radio"] {
+ // stylelint-disable-next-line property-disallowed-list
+ border-radius: $form-check-radio-border-radius;
+ }
+
+ &:active {
+ filter: $form-check-input-active-filter;
+ }
+
+ &:focus {
+ border-color: $form-check-input-focus-border;
+ outline: 0;
+ box-shadow: $form-check-input-focus-box-shadow;
+ }
+
+ &:checked {
+ background-color: $form-check-input-checked-bg-color;
+ border-color: $form-check-input-checked-border-color;
+
+ &[type="checkbox"] {
+ @if $enable-gradients {
+ background-image: escape-svg($form-check-input-checked-bg-image), var(--#{$prefix}gradient);
+ } @else {
+ background-image: escape-svg($form-check-input-checked-bg-image);
+ }
+ }
+
+ &[type="radio"] {
+ @if $enable-gradients {
+ background-image: escape-svg($form-check-radio-checked-bg-image), var(--#{$prefix}gradient);
+ } @else {
+ background-image: escape-svg($form-check-radio-checked-bg-image);
+ }
+ }
+ }
+
+ &[type="checkbox"]:indeterminate {
+ background-color: $form-check-input-indeterminate-bg-color;
+ border-color: $form-check-input-indeterminate-border-color;
+
+ @if $enable-gradients {
+ background-image: escape-svg($form-check-input-indeterminate-bg-image), var(--#{$prefix}gradient);
+ } @else {
+ background-image: escape-svg($form-check-input-indeterminate-bg-image);
+ }
+ }
+
+ &:disabled {
+ pointer-events: none;
+ filter: none;
+ opacity: $form-check-input-disabled-opacity;
+ }
+
+ // Use disabled attribute in addition of :disabled pseudo-class
+ // See: https://github.com/twbs/bootstrap/issues/28247
+ &[disabled],
+ &:disabled {
+ ~ .form-check-label {
+ cursor: default;
+ opacity: $form-check-label-disabled-opacity;
+ }
+ }
+}
+
+.form-check-label {
+ color: $form-check-label-color;
+ cursor: $form-check-label-cursor;
+}
+
+//
+// Switch
+//
+
+.form-switch {
+ padding-left: $form-switch-padding-start;
+
+ .form-check-input {
+ width: $form-switch-width;
+ margin-left: $form-switch-padding-start * -1;
+ background-image: escape-svg($form-switch-bg-image);
+ background-position: left center;
+ @include border-radius($form-switch-border-radius);
+ @include transition($form-switch-transition);
+
+ &:focus {
+ background-image: escape-svg($form-switch-focus-bg-image);
+ }
+
+ &:checked {
+ background-position: $form-switch-checked-bg-position;
+
+ @if $enable-gradients {
+ background-image: escape-svg($form-switch-checked-bg-image), var(--#{$prefix}gradient);
+ } @else {
+ background-image: escape-svg($form-switch-checked-bg-image);
+ }
+ }
+ }
+
+ &.form-check-reverse {
+ padding-right: $form-switch-padding-start;
+ padding-left: 0;
+
+ .form-check-input {
+ margin-right: $form-switch-padding-start * -1;
+ margin-left: 0;
+ }
+ }
+}
+
+.form-check-inline {
+ display: inline-block;
+ margin-right: $form-check-inline-margin-end;
+}
+
+.btn-check {
+ position: absolute;
+ clip: rect(0, 0, 0, 0);
+ pointer-events: none;
+
+ &[disabled],
+ &:disabled {
+ + .btn {
+ pointer-events: none;
+ filter: none;
+ opacity: $form-check-btn-check-disabled-opacity;
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-control.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-control.scss
new file mode 100644
index 0000000000..e707c57ea2
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-control.scss
@@ -0,0 +1,194 @@
+//
+// General form controls (plus a few specific high-level interventions)
+//
+
+.form-control {
+ display: block;
+ width: 100%;
+ padding: $input-padding-y $input-padding-x;
+ font-family: $input-font-family;
+ @include font-size($input-font-size);
+ font-weight: $input-font-weight;
+ line-height: $input-line-height;
+ color: $input-color;
+ background-color: $input-bg;
+ background-clip: padding-box;
+ border: $input-border-width solid $input-border-color;
+ appearance: none; // Fix appearance for date inputs in Safari
+
+ // Note: This has no effect on <select>s in some browsers, due to the limited stylability of `<select>`s in CSS.
+ @include border-radius($input-border-radius, 0);
+
+ @include box-shadow($input-box-shadow);
+ @include transition($input-transition);
+
+ &[type="file"] {
+ overflow: hidden; // prevent pseudo element button overlap
+
+ &:not(:disabled):not([readonly]) {
+ cursor: pointer;
+ }
+ }
+
+ // Customize the `:focus` state to imitate native WebKit styles.
+ &:focus {
+ color: $input-focus-color;
+ background-color: $input-focus-bg;
+ border-color: $input-focus-border-color;
+ outline: 0;
+ @if $enable-shadows {
+ @include box-shadow($input-box-shadow, $input-focus-box-shadow);
+ } @else {
+ // Avoid using mixin so we can pass custom focus shadow properly
+ box-shadow: $input-focus-box-shadow;
+ }
+ }
+
+ // Add some height to date inputs on iOS
+ // https://github.com/twbs/bootstrap/issues/23307
+ // TODO: we can remove this workaround once https://bugs.webkit.org/show_bug.cgi?id=198959 is resolved
+ &::-webkit-date-and-time-value {
+ // Multiply line-height by 1em if it has no unit
+ height: if(unit($input-line-height) == "", $input-line-height * 1em, $input-line-height);
+ }
+
+ // Placeholder
+ &::placeholder {
+ color: $input-placeholder-color;
+ // Override Firefox's unusual default opacity; see https://github.com/twbs/bootstrap/pull/11526.
+ opacity: 1;
+ }
+
+ // Disabled inputs
+ //
+ // HTML5 says that controls under a fieldset > legend:first-child won't be
+ // disabled if the fieldset is disabled. Due to implementation difficulty, we
+ // don't honor that edge case; we style them as disabled anyway.
+ &:disabled {
+ color: $input-disabled-color;
+ background-color: $input-disabled-bg;
+ border-color: $input-disabled-border-color;
+ // iOS fix for unreadable disabled content; see https://github.com/twbs/bootstrap/issues/11655.
+ opacity: 1;
+ }
+
+ // File input buttons theming
+ &::file-selector-button {
+ padding: $input-padding-y $input-padding-x;
+ margin: (-$input-padding-y) (-$input-padding-x);
+ margin-inline-end: $input-padding-x;
+ color: $form-file-button-color;
+ @include gradient-bg($form-file-button-bg);
+ pointer-events: none;
+ border-color: inherit;
+ border-style: solid;
+ border-width: 0;
+ border-inline-end-width: $input-border-width;
+ border-radius: 0; // stylelint-disable-line property-disallowed-list
+ @include transition($btn-transition);
+ }
+
+ &:hover:not(:disabled):not([readonly])::file-selector-button {
+ background-color: $form-file-button-hover-bg;
+ }
+}
+
+// Readonly controls as plain text
+//
+// Apply class to a readonly input to make it appear like regular plain
+// text (without any border, background color, focus indicator)
+
+.form-control-plaintext {
+ display: block;
+ width: 100%;
+ padding: $input-padding-y 0;
+ margin-bottom: 0; // match inputs if this class comes on inputs with default margins
+ line-height: $input-line-height;
+ color: $input-plaintext-color;
+ background-color: transparent;
+ border: solid transparent;
+ border-width: $input-border-width 0;
+
+ &:focus {
+ outline: 0;
+ }
+
+ &.form-control-sm,
+ &.form-control-lg {
+ padding-right: 0;
+ padding-left: 0;
+ }
+}
+
+// Form control sizing
+//
+// Build on `.form-control` with modifier classes to decrease or increase the
+// height and font-size of form controls.
+//
+// Repeated in `_input_group.scss` to avoid Sass extend issues.
+
+.form-control-sm {
+ min-height: $input-height-sm;
+ padding: $input-padding-y-sm $input-padding-x-sm;
+ @include font-size($input-font-size-sm);
+ @include border-radius($input-border-radius-sm);
+
+ &::file-selector-button {
+ padding: $input-padding-y-sm $input-padding-x-sm;
+ margin: (-$input-padding-y-sm) (-$input-padding-x-sm);
+ margin-inline-end: $input-padding-x-sm;
+ }
+}
+
+.form-control-lg {
+ min-height: $input-height-lg;
+ padding: $input-padding-y-lg $input-padding-x-lg;
+ @include font-size($input-font-size-lg);
+ @include border-radius($input-border-radius-lg);
+
+ &::file-selector-button {
+ padding: $input-padding-y-lg $input-padding-x-lg;
+ margin: (-$input-padding-y-lg) (-$input-padding-x-lg);
+ margin-inline-end: $input-padding-x-lg;
+ }
+}
+
+// Make sure textareas don't shrink too much when resized
+// https://github.com/twbs/bootstrap/pull/29124
+// stylelint-disable selector-no-qualifying-type
+textarea {
+ &.form-control {
+ min-height: $input-height;
+ }
+
+ &.form-control-sm {
+ min-height: $input-height-sm;
+ }
+
+ &.form-control-lg {
+ min-height: $input-height-lg;
+ }
+}
+// stylelint-enable selector-no-qualifying-type
+
+.form-control-color {
+ width: $form-color-width;
+ height: $input-height;
+ padding: $input-padding-y;
+
+ &:not(:disabled):not([readonly]) {
+ cursor: pointer;
+ }
+
+ &::-moz-color-swatch {
+ border: 0 !important; // stylelint-disable-line declaration-no-important
+ @include border-radius($input-border-radius);
+ }
+
+ &::-webkit-color-swatch {
+ @include border-radius($input-border-radius);
+ }
+
+ &.form-control-sm { height: $input-height-sm; }
+ &.form-control-lg { height: $input-height-lg; }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-range.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-range.scss
new file mode 100644
index 0000000000..6de42132ea
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-range.scss
@@ -0,0 +1,91 @@
+// Range
+//
+// Style range inputs the same across browsers. Vendor-specific rules for pseudo
+// elements cannot be mixed. As such, there are no shared styles for focus or
+// active states on prefixed selectors.
+
+.form-range {
+ width: 100%;
+ height: add($form-range-thumb-height, $form-range-thumb-focus-box-shadow-width * 2);
+ padding: 0; // Need to reset padding
+ background-color: transparent;
+ appearance: none;
+
+ &:focus {
+ outline: 0;
+
+ // Pseudo-elements must be split across multiple rulesets to have an effect.
+ // No box-shadow() mixin for focus accessibility.
+ &::-webkit-slider-thumb { box-shadow: $form-range-thumb-focus-box-shadow; }
+ &::-moz-range-thumb { box-shadow: $form-range-thumb-focus-box-shadow; }
+ }
+
+ &::-moz-focus-outer {
+ border: 0;
+ }
+
+ &::-webkit-slider-thumb {
+ width: $form-range-thumb-width;
+ height: $form-range-thumb-height;
+ margin-top: ($form-range-track-height - $form-range-thumb-height) * .5; // Webkit specific
+ @include gradient-bg($form-range-thumb-bg);
+ border: $form-range-thumb-border;
+ @include border-radius($form-range-thumb-border-radius);
+ @include box-shadow($form-range-thumb-box-shadow);
+ @include transition($form-range-thumb-transition);
+ appearance: none;
+
+ &:active {
+ @include gradient-bg($form-range-thumb-active-bg);
+ }
+ }
+
+ &::-webkit-slider-runnable-track {
+ width: $form-range-track-width;
+ height: $form-range-track-height;
+ color: transparent; // Why?
+ cursor: $form-range-track-cursor;
+ background-color: $form-range-track-bg;
+ border-color: transparent;
+ @include border-radius($form-range-track-border-radius);
+ @include box-shadow($form-range-track-box-shadow);
+ }
+
+ &::-moz-range-thumb {
+ width: $form-range-thumb-width;
+ height: $form-range-thumb-height;
+ @include gradient-bg($form-range-thumb-bg);
+ border: $form-range-thumb-border;
+ @include border-radius($form-range-thumb-border-radius);
+ @include box-shadow($form-range-thumb-box-shadow);
+ @include transition($form-range-thumb-transition);
+ appearance: none;
+
+ &:active {
+ @include gradient-bg($form-range-thumb-active-bg);
+ }
+ }
+
+ &::-moz-range-track {
+ width: $form-range-track-width;
+ height: $form-range-track-height;
+ color: transparent;
+ cursor: $form-range-track-cursor;
+ background-color: $form-range-track-bg;
+ border-color: transparent; // Firefox specific?
+ @include border-radius($form-range-track-border-radius);
+ @include box-shadow($form-range-track-box-shadow);
+ }
+
+ &:disabled {
+ pointer-events: none;
+
+ &::-webkit-slider-thumb {
+ background-color: $form-range-thumb-disabled-bg;
+ }
+
+ &::-moz-range-thumb {
+ background-color: $form-range-thumb-disabled-bg;
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-select.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-select.scss
new file mode 100644
index 0000000000..78c34b8fd6
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-select.scss
@@ -0,0 +1,71 @@
+// Select
+//
+// Replaces the browser default select with a custom one, mostly pulled from
+// https://primer.github.io/.
+
+.form-select {
+ display: block;
+ width: 100%;
+ padding: $form-select-padding-y $form-select-indicator-padding $form-select-padding-y $form-select-padding-x;
+ -moz-padding-start: subtract($form-select-padding-x, 3px); // See https://github.com/twbs/bootstrap/issues/32636
+ font-family: $form-select-font-family;
+ @include font-size($form-select-font-size);
+ font-weight: $form-select-font-weight;
+ line-height: $form-select-line-height;
+ color: $form-select-color;
+ background-color: $form-select-bg;
+ background-image: escape-svg($form-select-indicator);
+ background-repeat: no-repeat;
+ background-position: $form-select-bg-position;
+ background-size: $form-select-bg-size;
+ border: $form-select-border-width solid $form-select-border-color;
+ @include border-radius($form-select-border-radius, 0);
+ @include box-shadow($form-select-box-shadow);
+ @include transition($form-select-transition);
+ appearance: none;
+
+ &:focus {
+ border-color: $form-select-focus-border-color;
+ outline: 0;
+ @if $enable-shadows {
+ @include box-shadow($form-select-box-shadow, $form-select-focus-box-shadow);
+ } @else {
+ // Avoid using mixin so we can pass custom focus shadow properly
+ box-shadow: $form-select-focus-box-shadow;
+ }
+ }
+
+ &[multiple],
+ &[size]:not([size="1"]) {
+ padding-right: $form-select-padding-x;
+ background-image: none;
+ }
+
+ &:disabled {
+ color: $form-select-disabled-color;
+ background-color: $form-select-disabled-bg;
+ border-color: $form-select-disabled-border-color;
+ }
+
+ // Remove outline from select box in FF
+ &:-moz-focusring {
+ color: transparent;
+ text-shadow: 0 0 0 $form-select-color;
+ }
+}
+
+.form-select-sm {
+ padding-top: $form-select-padding-y-sm;
+ padding-bottom: $form-select-padding-y-sm;
+ padding-left: $form-select-padding-x-sm;
+ @include font-size($form-select-font-size-sm);
+ @include border-radius($form-select-border-radius-sm);
+}
+
+.form-select-lg {
+ padding-top: $form-select-padding-y-lg;
+ padding-bottom: $form-select-padding-y-lg;
+ padding-left: $form-select-padding-x-lg;
+ @include font-size($form-select-font-size-lg);
+ @include border-radius($form-select-border-radius-lg);
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-text.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-text.scss
new file mode 100644
index 0000000000..f080d1a234
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_form-text.scss
@@ -0,0 +1,11 @@
+//
+// Form text
+//
+
+.form-text {
+ margin-top: $form-text-margin-top;
+ @include font-size($form-text-font-size);
+ font-style: $form-text-font-style;
+ font-weight: $form-text-font-weight;
+ color: $form-text-color;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_input-group.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_input-group.scss
new file mode 100644
index 0000000000..247f74a7ac
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_input-group.scss
@@ -0,0 +1,132 @@
+//
+// Base styles
+//
+
+.input-group {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap; // For form validation feedback
+ align-items: stretch;
+ width: 100%;
+
+ > .form-control,
+ > .form-select,
+ > .form-floating {
+ position: relative; // For focus state's z-index
+ flex: 1 1 auto;
+ width: 1%;
+ min-width: 0; // https://stackoverflow.com/questions/36247140/why-dont-flex-items-shrink-past-content-size
+ }
+
+ // Bring the "active" form control to the top of surrounding elements
+ > .form-control:focus,
+ > .form-select:focus,
+ > .form-floating:focus-within {
+ z-index: 5;
+ }
+
+ // Ensure buttons are always above inputs for more visually pleasing borders.
+ // This isn't needed for `.input-group-text` since it shares the same border-color
+ // as our inputs.
+ .btn {
+ position: relative;
+ z-index: 2;
+
+ &:focus {
+ z-index: 5;
+ }
+ }
+}
+
+
+// Textual addons
+//
+// Serves as a catch-all element for any text or radio/checkbox input you wish
+// to prepend or append to an input.
+
+.input-group-text {
+ display: flex;
+ align-items: center;
+ padding: $input-group-addon-padding-y $input-group-addon-padding-x;
+ @include font-size($input-font-size); // Match inputs
+ font-weight: $input-group-addon-font-weight;
+ line-height: $input-line-height;
+ color: $input-group-addon-color;
+ text-align: center;
+ white-space: nowrap;
+ background-color: $input-group-addon-bg;
+ border: $input-border-width solid $input-group-addon-border-color;
+ @include border-radius($input-border-radius);
+}
+
+
+// Sizing
+//
+// Remix the default form control sizing classes into new ones for easier
+// manipulation.
+
+.input-group-lg > .form-control,
+.input-group-lg > .form-select,
+.input-group-lg > .input-group-text,
+.input-group-lg > .btn {
+ padding: $input-padding-y-lg $input-padding-x-lg;
+ @include font-size($input-font-size-lg);
+ @include border-radius($input-border-radius-lg);
+}
+
+.input-group-sm > .form-control,
+.input-group-sm > .form-select,
+.input-group-sm > .input-group-text,
+.input-group-sm > .btn {
+ padding: $input-padding-y-sm $input-padding-x-sm;
+ @include font-size($input-font-size-sm);
+ @include border-radius($input-border-radius-sm);
+}
+
+.input-group-lg > .form-select,
+.input-group-sm > .form-select {
+ padding-right: $form-select-padding-x + $form-select-indicator-padding;
+}
+
+
+// Rounded corners
+//
+// These rulesets must come after the sizing ones to properly override sm and lg
+// border-radius values when extending. They're more specific than we'd like
+// with the `.input-group >` part, but without it, we cannot override the sizing.
+
+// stylelint-disable-next-line no-duplicate-selectors
+.input-group {
+ &:not(.has-validation) {
+ > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),
+ > .dropdown-toggle:nth-last-child(n + 3),
+ > .form-floating:not(:last-child) > .form-control,
+ > .form-floating:not(:last-child) > .form-select {
+ @include border-end-radius(0);
+ }
+ }
+
+ &.has-validation {
+ > :nth-last-child(n + 3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),
+ > .dropdown-toggle:nth-last-child(n + 4),
+ > .form-floating:nth-last-child(n + 3) > .form-control,
+ > .form-floating:nth-last-child(n + 3) > .form-select {
+ @include border-end-radius(0);
+ }
+ }
+
+ $validation-messages: "";
+ @each $state in map-keys($form-validation-states) {
+ $validation-messages: $validation-messages + ":not(." + unquote($state) + "-tooltip)" + ":not(." + unquote($state) + "-feedback)";
+ }
+
+ > :not(:first-child):not(.dropdown-menu)#{$validation-messages} {
+ margin-left: -$input-border-width;
+ @include border-start-radius(0);
+ }
+
+ > .form-floating:not(:first-child) > .form-control,
+ > .form-floating:not(:first-child) > .form-select {
+ @include border-start-radius(0);
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_labels.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_labels.scss
new file mode 100644
index 0000000000..39ecafcd2f
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_labels.scss
@@ -0,0 +1,36 @@
+//
+// Labels
+//
+
+.form-label {
+ margin-bottom: $form-label-margin-bottom;
+ @include font-size($form-label-font-size);
+ font-style: $form-label-font-style;
+ font-weight: $form-label-font-weight;
+ color: $form-label-color;
+}
+
+// For use with horizontal and inline forms, when you need the label (or legend)
+// text to align with the form controls.
+.col-form-label {
+ padding-top: add($input-padding-y, $input-border-width);
+ padding-bottom: add($input-padding-y, $input-border-width);
+ margin-bottom: 0; // Override the `<legend>` default
+ @include font-size(inherit); // Override the `<legend>` default
+ font-style: $form-label-font-style;
+ font-weight: $form-label-font-weight;
+ line-height: $input-line-height;
+ color: $form-label-color;
+}
+
+.col-form-label-lg {
+ padding-top: add($input-padding-y-lg, $input-border-width);
+ padding-bottom: add($input-padding-y-lg, $input-border-width);
+ @include font-size($input-font-size-lg);
+}
+
+.col-form-label-sm {
+ padding-top: add($input-padding-y-sm, $input-border-width);
+ padding-bottom: add($input-padding-y-sm, $input-border-width);
+ @include font-size($input-font-size-sm);
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_validation.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_validation.scss
new file mode 100644
index 0000000000..c48123a716
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/forms/_validation.scss
@@ -0,0 +1,12 @@
+// Form validation
+//
+// Provide feedback to users when form field values are valid or invalid. Works
+// primarily for client-side validation via scoped `:invalid` and `:valid`
+// pseudo-classes but also includes `.is-invalid` and `.is-valid` classes for
+// server-side validation.
+
+// scss-docs-start form-validation-states-loop
+@each $state, $data in $form-validation-states {
+ @include form-validation-state($state, $data...);
+}
+// scss-docs-end form-validation-states-loop
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_clearfix.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_clearfix.scss
new file mode 100644
index 0000000000..e92522a94d
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_clearfix.scss
@@ -0,0 +1,3 @@
+.clearfix {
+ @include clearfix();
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_color-bg.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_color-bg.scss
new file mode 100644
index 0000000000..b5ce7709c1
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_color-bg.scss
@@ -0,0 +1,10 @@
+// stylelint-disable function-name-case
+
+// All-caps `RGBA()` function used because of this Sass bug: https://github.com/sass/node-sass/issues/2251
+@each $color, $value in $theme-colors {
+ $color-rgb: to-rgb($value);
+ .text-bg-#{$color} {
+ color: color-contrast($value) if($enable-important-utilities, !important, null);
+ background-color: RGBA($color-rgb, var(--#{$prefix}bg-opacity, 1)) if($enable-important-utilities, !important, null);
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_colored-links.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_colored-links.scss
new file mode 100644
index 0000000000..1cb4182801
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_colored-links.scss
@@ -0,0 +1,12 @@
+@each $color, $value in $theme-colors {
+ .link-#{$color} {
+ color: $value !important; // stylelint-disable-line declaration-no-important
+
+ @if $link-shade-percentage != 0 {
+ &:hover,
+ &:focus {
+ color: if(color-contrast($value) == $color-contrast-light, shade-color($value, $link-shade-percentage), tint-color($value, $link-shade-percentage)) !important; // stylelint-disable-line declaration-no-important
+ }
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_position.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_position.scss
new file mode 100644
index 0000000000..59103d9436
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_position.scss
@@ -0,0 +1,36 @@
+// Shorthand
+
+.fixed-top {
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ z-index: $zindex-fixed;
+}
+
+.fixed-bottom {
+ position: fixed;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: $zindex-fixed;
+}
+
+// Responsive sticky top and bottom
+@each $breakpoint in map-keys($grid-breakpoints) {
+ @include media-breakpoint-up($breakpoint) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+
+ .sticky#{$infix}-top {
+ position: sticky;
+ top: 0;
+ z-index: $zindex-sticky;
+ }
+
+ .sticky#{$infix}-bottom {
+ position: sticky;
+ bottom: 0;
+ z-index: $zindex-sticky;
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_ratio.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_ratio.scss
new file mode 100644
index 0000000000..b6a7654c52
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_ratio.scss
@@ -0,0 +1,26 @@
+// Credit: Nicolas Gallagher and SUIT CSS.
+
+.ratio {
+ position: relative;
+ width: 100%;
+
+ &::before {
+ display: block;
+ padding-top: var(--#{$prefix}aspect-ratio);
+ content: "";
+ }
+
+ > * {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+@each $key, $ratio in $aspect-ratios {
+ .ratio-#{$key} {
+ --#{$prefix}aspect-ratio: #{$ratio};
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_stacks.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_stacks.scss
new file mode 100644
index 0000000000..6cd237ae6d
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_stacks.scss
@@ -0,0 +1,15 @@
+// scss-docs-start stacks
+.hstack {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ align-self: stretch;
+}
+
+.vstack {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ align-self: stretch;
+}
+// scss-docs-end stacks
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_stretched-link.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_stretched-link.scss
new file mode 100644
index 0000000000..71a1c755af
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_stretched-link.scss
@@ -0,0 +1,15 @@
+//
+// Stretched link
+//
+
+.stretched-link {
+ &::#{$stretched-link-pseudo-element} {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: $stretched-link-z-index;
+ content: "";
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_text-truncation.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_text-truncation.scss
new file mode 100644
index 0000000000..6421dac9a8
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_text-truncation.scss
@@ -0,0 +1,7 @@
+//
+// Text truncation
+//
+
+.text-truncate {
+ @include text-truncate();
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_visually-hidden.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_visually-hidden.scss
new file mode 100644
index 0000000000..4760ff03d1
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_visually-hidden.scss
@@ -0,0 +1,8 @@
+//
+// Visually hidden
+//
+
+.visually-hidden,
+.visually-hidden-focusable:not(:focus):not(:focus-within) {
+ @include visually-hidden();
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_vr.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_vr.scss
new file mode 100644
index 0000000000..9bca099536
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/helpers/_vr.scss
@@ -0,0 +1,8 @@
+.vr {
+ display: inline-block;
+ align-self: stretch;
+ width: 1px;
+ min-height: 1em;
+ background-color: currentcolor;
+ opacity: $hr-opacity;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_alert.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_alert.scss
new file mode 100644
index 0000000000..231f068ec4
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_alert.scss
@@ -0,0 +1,15 @@
+// scss-docs-start alert-variant-mixin
+@mixin alert-variant($background, $border, $color) {
+ --#{$prefix}alert-color: #{$color};
+ --#{$prefix}alert-bg: #{$background};
+ --#{$prefix}alert-border-color: #{$border};
+
+ @if $enable-gradients {
+ background-image: var(--#{$prefix}gradient);
+ }
+
+ .alert-link {
+ color: shade-color($color, 20%);
+ }
+}
+// scss-docs-end alert-variant-mixin
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_backdrop.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_backdrop.scss
new file mode 100644
index 0000000000..9705ae9eea
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_backdrop.scss
@@ -0,0 +1,14 @@
+// Shared between modals and offcanvases
+@mixin overlay-backdrop($zindex, $backdrop-bg, $backdrop-opacity) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: $zindex;
+ width: 100vw;
+ height: 100vh;
+ background-color: $backdrop-bg;
+
+ // Fade for backdrop
+ &.fade { opacity: 0; }
+ &.show { opacity: $backdrop-opacity; }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_banner.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_banner.scss
new file mode 100644
index 0000000000..8b859abb80
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_banner.scss
@@ -0,0 +1,9 @@
+@mixin bsBanner($file) {
+ /*!
+ * Bootstrap #{$file} v5.2.1 (https://getbootstrap.com/)
+ * Copyright 2011-2022 The Bootstrap Authors
+ * Copyright 2011-2022 Twitter, Inc.
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
+ */
+}
+
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_border-radius.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_border-radius.scss
new file mode 100644
index 0000000000..616decbce3
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_border-radius.scss
@@ -0,0 +1,78 @@
+// stylelint-disable property-disallowed-list
+// Single side border-radius
+
+// Helper function to replace negative values with 0
+@function valid-radius($radius) {
+ $return: ();
+ @each $value in $radius {
+ @if type-of($value) == number {
+ $return: append($return, max($value, 0));
+ } @else {
+ $return: append($return, $value);
+ }
+ }
+ @return $return;
+}
+
+// scss-docs-start border-radius-mixins
+@mixin border-radius($radius: $border-radius, $fallback-border-radius: false) {
+ @if $enable-rounded {
+ border-radius: valid-radius($radius);
+ }
+ @else if $fallback-border-radius != false {
+ border-radius: $fallback-border-radius;
+ }
+}
+
+@mixin border-top-radius($radius: $border-radius) {
+ @if $enable-rounded {
+ border-top-left-radius: valid-radius($radius);
+ border-top-right-radius: valid-radius($radius);
+ }
+}
+
+@mixin border-end-radius($radius: $border-radius) {
+ @if $enable-rounded {
+ border-top-right-radius: valid-radius($radius);
+ border-bottom-right-radius: valid-radius($radius);
+ }
+}
+
+@mixin border-bottom-radius($radius: $border-radius) {
+ @if $enable-rounded {
+ border-bottom-right-radius: valid-radius($radius);
+ border-bottom-left-radius: valid-radius($radius);
+ }
+}
+
+@mixin border-start-radius($radius: $border-radius) {
+ @if $enable-rounded {
+ border-top-left-radius: valid-radius($radius);
+ border-bottom-left-radius: valid-radius($radius);
+ }
+}
+
+@mixin border-top-start-radius($radius: $border-radius) {
+ @if $enable-rounded {
+ border-top-left-radius: valid-radius($radius);
+ }
+}
+
+@mixin border-top-end-radius($radius: $border-radius) {
+ @if $enable-rounded {
+ border-top-right-radius: valid-radius($radius);
+ }
+}
+
+@mixin border-bottom-end-radius($radius: $border-radius) {
+ @if $enable-rounded {
+ border-bottom-right-radius: valid-radius($radius);
+ }
+}
+
+@mixin border-bottom-start-radius($radius: $border-radius) {
+ @if $enable-rounded {
+ border-bottom-left-radius: valid-radius($radius);
+ }
+}
+// scss-docs-end border-radius-mixins
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_box-shadow.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_box-shadow.scss
new file mode 100644
index 0000000000..4172541f3f
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_box-shadow.scss
@@ -0,0 +1,18 @@
+@mixin box-shadow($shadow...) {
+ @if $enable-shadows {
+ $result: ();
+
+ @each $value in $shadow {
+ @if $value != null {
+ $result: append($result, $value, "comma");
+ }
+ @if $value == none and length($shadow) > 1 {
+ @warn "The keyword 'none' must be used as a single argument.";
+ }
+ }
+
+ @if (length($result) > 0) {
+ box-shadow: $result;
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_breakpoints.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_breakpoints.scss
new file mode 100644
index 0000000000..286be893d7
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_breakpoints.scss
@@ -0,0 +1,127 @@
+// Breakpoint viewport sizes and media queries.
+//
+// Breakpoints are defined as a map of (name: minimum width), order from small to large:
+//
+// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)
+//
+// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.
+
+// Name of the next breakpoint, or null for the last breakpoint.
+//
+// >> breakpoint-next(sm)
+// md
+// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))
+// md
+// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))
+// md
+@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {
+ $n: index($breakpoint-names, $name);
+ @if not $n {
+ @error "breakpoint `#{$name}` not found in `#{$breakpoints}`";
+ }
+ @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);
+}
+
+// Minimum breakpoint width. Null for the smallest (first) breakpoint.
+//
+// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))
+// 576px
+@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {
+ $min: map-get($breakpoints, $name);
+ @return if($min != 0, $min, null);
+}
+
+// Maximum breakpoint width.
+// The maximum value is reduced by 0.02px to work around the limitations of
+// `min-` and `max-` prefixes and viewports with fractional widths.
+// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max
+// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.
+// See https://bugs.webkit.org/show_bug.cgi?id=178261
+//
+// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))
+// 767.98px
+@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {
+ $max: map-get($breakpoints, $name);
+ @return if($max and $max > 0, $max - .02, null);
+}
+
+// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.
+// Useful for making responsive utilities.
+//
+// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))
+// "" (Returns a blank string)
+// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))
+// "-sm"
+@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {
+ @return if(breakpoint-min($name, $breakpoints) == null, "", "-#{$name}");
+}
+
+// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.
+// Makes the @content apply to the given breakpoint and wider.
+@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {
+ $min: breakpoint-min($name, $breakpoints);
+ @if $min {
+ @media (min-width: $min) {
+ @content;
+ }
+ } @else {
+ @content;
+ }
+}
+
+// Media of at most the maximum breakpoint width. No query for the largest breakpoint.
+// Makes the @content apply to the given breakpoint and narrower.
+@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {
+ $max: breakpoint-max($name, $breakpoints);
+ @if $max {
+ @media (max-width: $max) {
+ @content;
+ }
+ } @else {
+ @content;
+ }
+}
+
+// Media that spans multiple breakpoint widths.
+// Makes the @content apply between the min and max breakpoints
+@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {
+ $min: breakpoint-min($lower, $breakpoints);
+ $max: breakpoint-max($upper, $breakpoints);
+
+ @if $min != null and $max != null {
+ @media (min-width: $min) and (max-width: $max) {
+ @content;
+ }
+ } @else if $max == null {
+ @include media-breakpoint-up($lower, $breakpoints) {
+ @content;
+ }
+ } @else if $min == null {
+ @include media-breakpoint-down($upper, $breakpoints) {
+ @content;
+ }
+ }
+}
+
+// Media between the breakpoint's minimum and maximum widths.
+// No minimum for the smallest breakpoint, and no maximum for the largest one.
+// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.
+@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {
+ $min: breakpoint-min($name, $breakpoints);
+ $next: breakpoint-next($name, $breakpoints);
+ $max: breakpoint-max($next, $breakpoints);
+
+ @if $min != null and $max != null {
+ @media (min-width: $min) and (max-width: $max) {
+ @content;
+ }
+ } @else if $max == null {
+ @include media-breakpoint-up($name, $breakpoints) {
+ @content;
+ }
+ } @else if $min == null {
+ @include media-breakpoint-down($next, $breakpoints) {
+ @content;
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_buttons.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_buttons.scss
new file mode 100644
index 0000000000..cf087fda78
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_buttons.scss
@@ -0,0 +1,70 @@
+// Button variants
+//
+// Easily pump out default styles, as well as :hover, :focus, :active,
+// and disabled options for all buttons
+
+// scss-docs-start btn-variant-mixin
+@mixin button-variant(
+ $background,
+ $border,
+ $color: color-contrast($background),
+ $hover-background: if($color == $color-contrast-light, shade-color($background, $btn-hover-bg-shade-amount), tint-color($background, $btn-hover-bg-tint-amount)),
+ $hover-border: if($color == $color-contrast-light, shade-color($border, $btn-hover-border-shade-amount), tint-color($border, $btn-hover-border-tint-amount)),
+ $hover-color: color-contrast($hover-background),
+ $active-background: if($color == $color-contrast-light, shade-color($background, $btn-active-bg-shade-amount), tint-color($background, $btn-active-bg-tint-amount)),
+ $active-border: if($color == $color-contrast-light, shade-color($border, $btn-active-border-shade-amount), tint-color($border, $btn-active-border-tint-amount)),
+ $active-color: color-contrast($active-background),
+ $disabled-background: $background,
+ $disabled-border: $border,
+ $disabled-color: color-contrast($disabled-background)
+) {
+ --#{$prefix}btn-color: #{$color};
+ --#{$prefix}btn-bg: #{$background};
+ --#{$prefix}btn-border-color: #{$border};
+ --#{$prefix}btn-hover-color: #{$hover-color};
+ --#{$prefix}btn-hover-bg: #{$hover-background};
+ --#{$prefix}btn-hover-border-color: #{$hover-border};
+ --#{$prefix}btn-focus-shadow-rgb: #{to-rgb(mix($color, $border, 15%))};
+ --#{$prefix}btn-active-color: #{$active-color};
+ --#{$prefix}btn-active-bg: #{$active-background};
+ --#{$prefix}btn-active-border-color: #{$active-border};
+ --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow};
+ --#{$prefix}btn-disabled-color: #{$disabled-color};
+ --#{$prefix}btn-disabled-bg: #{$disabled-background};
+ --#{$prefix}btn-disabled-border-color: #{$disabled-border};
+}
+// scss-docs-end btn-variant-mixin
+
+// scss-docs-start btn-outline-variant-mixin
+@mixin button-outline-variant(
+ $color,
+ $color-hover: color-contrast($color),
+ $active-background: $color,
+ $active-border: $color,
+ $active-color: color-contrast($active-background)
+) {
+ --#{$prefix}btn-color: #{$color};
+ --#{$prefix}btn-border-color: #{$color};
+ --#{$prefix}btn-hover-color: #{$color-hover};
+ --#{$prefix}btn-hover-bg: #{$active-background};
+ --#{$prefix}btn-hover-border-color: #{$active-border};
+ --#{$prefix}btn-focus-shadow-rgb: #{to-rgb($color)};
+ --#{$prefix}btn-active-color: #{$active-color};
+ --#{$prefix}btn-active-bg: #{$active-background};
+ --#{$prefix}btn-active-border-color: #{$active-border};
+ --#{$prefix}btn-active-shadow: #{$btn-active-box-shadow};
+ --#{$prefix}btn-disabled-color: #{$color};
+ --#{$prefix}btn-disabled-bg: transparent;
+ --#{$prefix}btn-disabled-border-color: #{$color};
+ --#{$prefix}gradient: none;
+}
+// scss-docs-end btn-outline-variant-mixin
+
+// scss-docs-start btn-size-mixin
+@mixin button-size($padding-y, $padding-x, $font-size, $border-radius) {
+ --#{$prefix}btn-padding-y: #{$padding-y};
+ --#{$prefix}btn-padding-x: #{$padding-x};
+ @include rfs($font-size, --#{$prefix}btn-font-size);
+ --#{$prefix}btn-border-radius: #{$border-radius};
+}
+// scss-docs-end btn-size-mixin
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_caret.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_caret.scss
new file mode 100644
index 0000000000..4b0f0360ba
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_caret.scss
@@ -0,0 +1,64 @@
+// scss-docs-start caret-mixins
+@mixin caret-down {
+ border-top: $caret-width solid;
+ border-right: $caret-width solid transparent;
+ border-bottom: 0;
+ border-left: $caret-width solid transparent;
+}
+
+@mixin caret-up {
+ border-top: 0;
+ border-right: $caret-width solid transparent;
+ border-bottom: $caret-width solid;
+ border-left: $caret-width solid transparent;
+}
+
+@mixin caret-end {
+ border-top: $caret-width solid transparent;
+ border-right: 0;
+ border-bottom: $caret-width solid transparent;
+ border-left: $caret-width solid;
+}
+
+@mixin caret-start {
+ border-top: $caret-width solid transparent;
+ border-right: $caret-width solid;
+ border-bottom: $caret-width solid transparent;
+}
+
+@mixin caret($direction: down) {
+ @if $enable-caret {
+ &::after {
+ display: inline-block;
+ margin-left: $caret-spacing;
+ vertical-align: $caret-vertical-align;
+ content: "";
+ @if $direction == down {
+ @include caret-down();
+ } @else if $direction == up {
+ @include caret-up();
+ } @else if $direction == end {
+ @include caret-end();
+ }
+ }
+
+ @if $direction == start {
+ &::after {
+ display: none;
+ }
+
+ &::before {
+ display: inline-block;
+ margin-right: $caret-spacing;
+ vertical-align: $caret-vertical-align;
+ content: "";
+ @include caret-start();
+ }
+ }
+
+ &:empty::after {
+ margin-left: 0;
+ }
+ }
+}
+// scss-docs-end caret-mixins
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_clearfix.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_clearfix.scss
new file mode 100644
index 0000000000..ffc62bb285
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_clearfix.scss
@@ -0,0 +1,9 @@
+// scss-docs-start clearfix
+@mixin clearfix() {
+ &::after {
+ display: block;
+ clear: both;
+ content: "";
+ }
+}
+// scss-docs-end clearfix
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_color-scheme.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_color-scheme.scss
new file mode 100644
index 0000000000..90497aa0a9
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_color-scheme.scss
@@ -0,0 +1,7 @@
+// scss-docs-start mixin-color-scheme
+@mixin color-scheme($name) {
+ @media (prefers-color-scheme: #{$name}) {
+ @content;
+ }
+}
+// scss-docs-end mixin-color-scheme
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_container.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_container.scss
new file mode 100644
index 0000000000..b9f33519e2
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_container.scss
@@ -0,0 +1,11 @@
+// Container mixins
+
+@mixin make-container($gutter: $container-padding-x) {
+ --#{$prefix}gutter-x: #{$gutter};
+ --#{$prefix}gutter-y: 0;
+ width: 100%;
+ padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list
+ padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list
+ margin-right: auto;
+ margin-left: auto;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_deprecate.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_deprecate.scss
new file mode 100644
index 0000000000..df070bc596
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_deprecate.scss
@@ -0,0 +1,10 @@
+// Deprecate mixin
+//
+// This mixin can be used to deprecate mixins or functions.
+// `$enable-deprecation-messages` is a global variable, `$ignore-warning` is a variable that can be passed to
+// some deprecated mixins to suppress the warning (for example if the mixin is still be used in the current version of Bootstrap)
+@mixin deprecate($name, $deprecate-version, $remove-version, $ignore-warning: false) {
+ @if ($enable-deprecation-messages != false and $ignore-warning != true) {
+ @warn "#{$name} has been deprecated as of #{$deprecate-version}. It will be removed entirely in #{$remove-version}.";
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_forms.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_forms.scss
new file mode 100644
index 0000000000..2a853a7894
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_forms.scss
@@ -0,0 +1,152 @@
+// This mixin uses an `if()` technique to be compatible with Dart Sass
+// See https://github.com/sass/sass/issues/1873#issuecomment-152293725 for more details
+
+// scss-docs-start form-validation-mixins
+@mixin form-validation-state-selector($state) {
+ @if ($state == "valid" or $state == "invalid") {
+ .was-validated #{if(&, "&", "")}:#{$state},
+ #{if(&, "&", "")}.is-#{$state} {
+ @content;
+ }
+ } @else {
+ #{if(&, "&", "")}.is-#{$state} {
+ @content;
+ }
+ }
+}
+
+@mixin form-validation-state(
+ $state,
+ $color,
+ $icon,
+ $tooltip-color: color-contrast($color),
+ $tooltip-bg-color: rgba($color, $form-feedback-tooltip-opacity),
+ $focus-box-shadow: 0 0 $input-btn-focus-blur $input-focus-width rgba($color, $input-btn-focus-color-opacity)
+) {
+ .#{$state}-feedback {
+ display: none;
+ width: 100%;
+ margin-top: $form-feedback-margin-top;
+ @include font-size($form-feedback-font-size);
+ font-style: $form-feedback-font-style;
+ color: $color;
+ }
+
+ .#{$state}-tooltip {
+ position: absolute;
+ top: 100%;
+ z-index: 5;
+ display: none;
+ max-width: 100%; // Contain to parent when possible
+ padding: $form-feedback-tooltip-padding-y $form-feedback-tooltip-padding-x;
+ margin-top: .1rem;
+ @include font-size($form-feedback-tooltip-font-size);
+ line-height: $form-feedback-tooltip-line-height;
+ color: $tooltip-color;
+ background-color: $tooltip-bg-color;
+ @include border-radius($form-feedback-tooltip-border-radius);
+ }
+
+ @include form-validation-state-selector($state) {
+ ~ .#{$state}-feedback,
+ ~ .#{$state}-tooltip {
+ display: block;
+ }
+ }
+
+ .form-control {
+ @include form-validation-state-selector($state) {
+ border-color: $color;
+
+ @if $enable-validation-icons {
+ padding-right: $input-height-inner;
+ background-image: escape-svg($icon);
+ background-repeat: no-repeat;
+ background-position: right $input-height-inner-quarter center;
+ background-size: $input-height-inner-half $input-height-inner-half;
+ }
+
+ &:focus {
+ border-color: $color;
+ box-shadow: $focus-box-shadow;
+ }
+ }
+ }
+
+ // stylelint-disable-next-line selector-no-qualifying-type
+ textarea.form-control {
+ @include form-validation-state-selector($state) {
+ @if $enable-validation-icons {
+ padding-right: $input-height-inner;
+ background-position: top $input-height-inner-quarter right $input-height-inner-quarter;
+ }
+ }
+ }
+
+ .form-select {
+ @include form-validation-state-selector($state) {
+ border-color: $color;
+
+ @if $enable-validation-icons {
+ &:not([multiple]):not([size]),
+ &:not([multiple])[size="1"] {
+ padding-right: $form-select-feedback-icon-padding-end;
+ background-image: escape-svg($form-select-indicator), escape-svg($icon);
+ background-position: $form-select-bg-position, $form-select-feedback-icon-position;
+ background-size: $form-select-bg-size, $form-select-feedback-icon-size;
+ }
+ }
+
+ &:focus {
+ border-color: $color;
+ box-shadow: $focus-box-shadow;
+ }
+ }
+ }
+
+ .form-control-color {
+ @include form-validation-state-selector($state) {
+ @if $enable-validation-icons {
+ width: add($form-color-width, $input-height-inner);
+ }
+ }
+ }
+
+ .form-check-input {
+ @include form-validation-state-selector($state) {
+ border-color: $color;
+
+ &:checked {
+ background-color: $color;
+ }
+
+ &:focus {
+ box-shadow: $focus-box-shadow;
+ }
+
+ ~ .form-check-label {
+ color: $color;
+ }
+ }
+ }
+ .form-check-inline .form-check-input {
+ ~ .#{$state}-feedback {
+ margin-left: .5em;
+ }
+ }
+
+ .input-group {
+ > .form-control:not(:focus),
+ > .form-select:not(:focus),
+ > .form-floating:not(:focus-within) {
+ @include form-validation-state-selector($state) {
+ @if $state == "valid" {
+ z-index: 3;
+ } @else if $state == "invalid" {
+ z-index: 4;
+ }
+ }
+ }
+ }
+}
+// scss-docs-end form-validation-mixins
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_gradients.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_gradients.scss
new file mode 100644
index 0000000000..608e18df2e
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_gradients.scss
@@ -0,0 +1,47 @@
+// Gradients
+
+// scss-docs-start gradient-bg-mixin
+@mixin gradient-bg($color: null) {
+ background-color: $color;
+
+ @if $enable-gradients {
+ background-image: var(--#{$prefix}gradient);
+ }
+}
+// scss-docs-end gradient-bg-mixin
+
+// scss-docs-start gradient-mixins
+// Horizontal gradient, from left to right
+//
+// Creates two color stops, start and end, by specifying a color and position for each color stop.
+@mixin gradient-x($start-color: $gray-700, $end-color: $gray-800, $start-percent: 0%, $end-percent: 100%) {
+ background-image: linear-gradient(to right, $start-color $start-percent, $end-color $end-percent);
+}
+
+// Vertical gradient, from top to bottom
+//
+// Creates two color stops, start and end, by specifying a color and position for each color stop.
+@mixin gradient-y($start-color: $gray-700, $end-color: $gray-800, $start-percent: null, $end-percent: null) {
+ background-image: linear-gradient(to bottom, $start-color $start-percent, $end-color $end-percent);
+}
+
+@mixin gradient-directional($start-color: $gray-700, $end-color: $gray-800, $deg: 45deg) {
+ background-image: linear-gradient($deg, $start-color, $end-color);
+}
+
+@mixin gradient-x-three-colors($start-color: $blue, $mid-color: $purple, $color-stop: 50%, $end-color: $red) {
+ background-image: linear-gradient(to right, $start-color, $mid-color $color-stop, $end-color);
+}
+
+@mixin gradient-y-three-colors($start-color: $blue, $mid-color: $purple, $color-stop: 50%, $end-color: $red) {
+ background-image: linear-gradient($start-color, $mid-color $color-stop, $end-color);
+}
+
+@mixin gradient-radial($inner-color: $gray-700, $outer-color: $gray-800) {
+ background-image: radial-gradient(circle, $inner-color, $outer-color);
+}
+
+@mixin gradient-striped($color: rgba($white, .15), $angle: 45deg) {
+ background-image: linear-gradient($angle, $color 25%, transparent 25%, transparent 50%, $color 50%, $color 75%, transparent 75%, transparent);
+}
+// scss-docs-end gradient-mixins
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_grid.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_grid.scss
new file mode 100644
index 0000000000..38e2239fdb
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_grid.scss
@@ -0,0 +1,151 @@
+// Grid system
+//
+// Generate semantic grid columns with these mixins.
+
+@mixin make-row($gutter: $grid-gutter-width) {
+ --#{$prefix}gutter-x: #{$gutter};
+ --#{$prefix}gutter-y: 0;
+ display: flex;
+ flex-wrap: wrap;
+ // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed
+ margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list
+ margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list
+ margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list
+}
+
+@mixin make-col-ready() {
+ // Add box sizing if only the grid is loaded
+ box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);
+ // Prevent columns from becoming too narrow when at smaller grid tiers by
+ // always setting `width: 100%;`. This works because we set the width
+ // later on to override this initial width.
+ flex-shrink: 0;
+ width: 100%;
+ max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid
+ padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list
+ padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list
+ margin-top: var(--#{$prefix}gutter-y);
+}
+
+@mixin make-col($size: false, $columns: $grid-columns) {
+ @if $size {
+ flex: 0 0 auto;
+ width: percentage(divide($size, $columns));
+
+ } @else {
+ flex: 1 1 0;
+ max-width: 100%;
+ }
+}
+
+@mixin make-col-auto() {
+ flex: 0 0 auto;
+ width: auto;
+}
+
+@mixin make-col-offset($size, $columns: $grid-columns) {
+ $num: divide($size, $columns);
+ margin-left: if($num == 0, 0, percentage($num));
+}
+
+// Row columns
+//
+// Specify on a parent element(e.g., .row) to force immediate children into NN
+// number of columns. Supports wrapping to new lines, but does not do a Masonry
+// style grid.
+@mixin row-cols($count) {
+ > * {
+ flex: 0 0 auto;
+ width: divide(100%, $count);
+ }
+}
+
+// Framework grid generation
+//
+// Used only by Bootstrap to generate the correct number of grid classes given
+// any value of `$grid-columns`.
+
+@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {
+ @each $breakpoint in map-keys($breakpoints) {
+ $infix: breakpoint-infix($breakpoint, $breakpoints);
+
+ @include media-breakpoint-up($breakpoint, $breakpoints) {
+ // Provide basic `.col-{bp}` classes for equal-width flexbox columns
+ .col#{$infix} {
+ flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4
+ }
+
+ .row-cols#{$infix}-auto > * {
+ @include make-col-auto();
+ }
+
+ @if $grid-row-columns > 0 {
+ @for $i from 1 through $grid-row-columns {
+ .row-cols#{$infix}-#{$i} {
+ @include row-cols($i);
+ }
+ }
+ }
+
+ .col#{$infix}-auto {
+ @include make-col-auto();
+ }
+
+ @if $columns > 0 {
+ @for $i from 1 through $columns {
+ .col#{$infix}-#{$i} {
+ @include make-col($i, $columns);
+ }
+ }
+
+ // `$columns - 1` because offsetting by the width of an entire row isn't possible
+ @for $i from 0 through ($columns - 1) {
+ @if not ($infix == "" and $i == 0) { // Avoid emitting useless .offset-0
+ .offset#{$infix}-#{$i} {
+ @include make-col-offset($i, $columns);
+ }
+ }
+ }
+ }
+
+ // Gutters
+ //
+ // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.
+ @each $key, $value in $gutters {
+ .g#{$infix}-#{$key},
+ .gx#{$infix}-#{$key} {
+ --#{$prefix}gutter-x: #{$value};
+ }
+
+ .g#{$infix}-#{$key},
+ .gy#{$infix}-#{$key} {
+ --#{$prefix}gutter-y: #{$value};
+ }
+ }
+ }
+ }
+}
+
+@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {
+ @each $breakpoint in map-keys($breakpoints) {
+ $infix: breakpoint-infix($breakpoint, $breakpoints);
+
+ @include media-breakpoint-up($breakpoint, $breakpoints) {
+ @if $columns > 0 {
+ @for $i from 1 through $columns {
+ .g-col#{$infix}-#{$i} {
+ grid-column: auto / span $i;
+ }
+ }
+
+ // Start with `1` because `0` is and invalid value.
+ // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.
+ @for $i from 1 through ($columns - 1) {
+ .g-start#{$infix}-#{$i} {
+ grid-column-start: $i;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_image.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_image.scss
new file mode 100644
index 0000000000..e1df779f84
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_image.scss
@@ -0,0 +1,16 @@
+// Image Mixins
+// - Responsive image
+// - Retina image
+
+
+// Responsive image
+//
+// Keep images from scaling beyond the width of their parents.
+
+@mixin img-fluid {
+ // Part 1: Set a maximum relative to the parent
+ max-width: 100%;
+ // Part 2: Override the height to auto, otherwise images will be stretched
+ // when setting a width and height attribute on the img element.
+ height: auto;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_list-group.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_list-group.scss
new file mode 100644
index 0000000000..e55415f2b8
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_list-group.scss
@@ -0,0 +1,24 @@
+// List Groups
+
+// scss-docs-start list-group-mixin
+@mixin list-group-item-variant($state, $background, $color) {
+ .list-group-item-#{$state} {
+ color: $color;
+ background-color: $background;
+
+ &.list-group-item-action {
+ &:hover,
+ &:focus {
+ color: $color;
+ background-color: shade-color($background, 10%);
+ }
+
+ &.active {
+ color: $white;
+ background-color: $color;
+ border-color: $color;
+ }
+ }
+ }
+}
+// scss-docs-end list-group-mixin
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_lists.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_lists.scss
new file mode 100644
index 0000000000..2518562669
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_lists.scss
@@ -0,0 +1,7 @@
+// Lists
+
+// Unstyled keeps list items block level, just removes default browser padding and list-style
+@mixin list-unstyled {
+ padding-left: 0;
+ list-style: none;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_pagination.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_pagination.scss
new file mode 100644
index 0000000000..0d657964fb
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_pagination.scss
@@ -0,0 +1,10 @@
+// Pagination
+
+// scss-docs-start pagination-mixin
+@mixin pagination-size($padding-y, $padding-x, $font-size, $border-radius) {
+ --#{$prefix}pagination-padding-x: #{$padding-x};
+ --#{$prefix}pagination-padding-y: #{$padding-y};
+ @include rfs($font-size, --#{$prefix}pagination-font-size);
+ --#{$prefix}pagination-border-radius: #{$border-radius};
+}
+// scss-docs-end pagination-mixin
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_reset-text.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_reset-text.scss
new file mode 100644
index 0000000000..f5bd1afec2
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_reset-text.scss
@@ -0,0 +1,17 @@
+@mixin reset-text {
+ font-family: $font-family-base;
+ // We deliberately do NOT reset font-size or overflow-wrap / word-wrap.
+ font-style: normal;
+ font-weight: $font-weight-normal;
+ line-height: $line-height-base;
+ text-align: left; // Fallback for where `start` is not supported
+ text-align: start;
+ text-decoration: none;
+ text-shadow: none;
+ text-transform: none;
+ letter-spacing: normal;
+ word-break: normal;
+ white-space: normal;
+ word-spacing: normal;
+ line-break: auto;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_resize.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_resize.scss
new file mode 100644
index 0000000000..66f233a63c
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_resize.scss
@@ -0,0 +1,6 @@
+// Resize anything
+
+@mixin resizable($direction) {
+ overflow: auto; // Per CSS3 UI, `resize` only applies when `overflow` isn't `visible`
+ resize: $direction; // Options: horizontal, vertical, both
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_table-variants.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_table-variants.scss
new file mode 100644
index 0000000000..ae43ec63d9
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_table-variants.scss
@@ -0,0 +1,24 @@
+// scss-docs-start table-variant
+@mixin table-variant($state, $background) {
+ .table-#{$state} {
+ $color: color-contrast(opaque($body-bg, $background));
+ $hover-bg: mix($color, $background, percentage($table-hover-bg-factor));
+ $striped-bg: mix($color, $background, percentage($table-striped-bg-factor));
+ $active-bg: mix($color, $background, percentage($table-active-bg-factor));
+ $border-color: mix($color, $background, percentage($table-border-factor));
+
+ --#{$prefix}table-color: #{$color};
+ --#{$prefix}table-bg: #{$background};
+ --#{$prefix}table-border-color: #{$border-color};
+ --#{$prefix}table-striped-bg: #{$striped-bg};
+ --#{$prefix}table-striped-color: #{color-contrast($striped-bg)};
+ --#{$prefix}table-active-bg: #{$active-bg};
+ --#{$prefix}table-active-color: #{color-contrast($active-bg)};
+ --#{$prefix}table-hover-bg: #{$hover-bg};
+ --#{$prefix}table-hover-color: #{color-contrast($hover-bg)};
+
+ color: var(--#{$prefix}table-color);
+ border-color: var(--#{$prefix}table-border-color);
+ }
+}
+// scss-docs-end table-variant
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_text-truncate.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_text-truncate.scss
new file mode 100644
index 0000000000..3504bb1aa5
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_text-truncate.scss
@@ -0,0 +1,8 @@
+// Text truncate
+// Requires inline-block or block for proper styling
+
+@mixin text-truncate() {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_transition.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_transition.scss
new file mode 100644
index 0000000000..d437f6d8f4
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_transition.scss
@@ -0,0 +1,26 @@
+// stylelint-disable property-disallowed-list
+@mixin transition($transition...) {
+ @if length($transition) == 0 {
+ $transition: $transition-base;
+ }
+
+ @if length($transition) > 1 {
+ @each $value in $transition {
+ @if $value == null or $value == none {
+ @warn "The keyword 'none' or 'null' must be used as a single argument.";
+ }
+ }
+ }
+
+ @if $enable-transitions {
+ @if nth($transition, 1) != null {
+ transition: $transition;
+ }
+
+ @if $enable-reduced-motion and nth($transition, 1) != null and nth($transition, 1) != none {
+ @media (prefers-reduced-motion: reduce) {
+ transition: none;
+ }
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_utilities.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_utilities.scss
new file mode 100644
index 0000000000..59a2374617
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_utilities.scss
@@ -0,0 +1,97 @@
+// Utility generator
+// Used to generate utilities & print utilities
+@mixin generate-utility($utility, $infix, $is-rfs-media-query: false) {
+ $values: map-get($utility, values);
+
+ // If the values are a list or string, convert it into a map
+ @if type-of($values) == "string" or type-of(nth($values, 1)) != "list" {
+ $values: zip($values, $values);
+ }
+
+ @each $key, $value in $values {
+ $properties: map-get($utility, property);
+
+ // Multiple properties are possible, for example with vertical or horizontal margins or paddings
+ @if type-of($properties) == "string" {
+ $properties: append((), $properties);
+ }
+
+ // Use custom class if present
+ $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));
+ $property-class: if($property-class == null, "", $property-class);
+
+ // Use custom CSS variable name if present, otherwise default to `class`
+ $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));
+
+ // State params to generate pseudo-classes
+ $state: if(map-has-key($utility, state), map-get($utility, state), ());
+
+ $infix: if($property-class == "" and str-slice($infix, 1, 1) == "-", str-slice($infix, 2), $infix);
+
+ // Don't prefix if value key is null (e.g. with shadow class)
+ $property-class-modifier: if($key, if($property-class == "" and $infix == "", "", "-") + $key, "");
+
+ @if map-get($utility, rfs) {
+ // Inside the media query
+ @if $is-rfs-media-query {
+ $val: rfs-value($value);
+
+ // Do not render anything if fluid and non fluid values are the same
+ $value: if($val == rfs-fluid-value($value), null, $val);
+ }
+ @else {
+ $value: rfs-fluid-value($value);
+ }
+ }
+
+ $is-css-var: map-get($utility, css-var);
+ $is-local-vars: map-get($utility, local-vars);
+ $is-rtl: map-get($utility, rtl);
+
+ @if $value != null {
+ @if $is-rtl == false {
+ /* rtl:begin:remove */
+ }
+
+ @if $is-css-var {
+ .#{$property-class + $infix + $property-class-modifier} {
+ --#{$prefix}#{$css-variable-name}: #{$value};
+ }
+
+ @each $pseudo in $state {
+ .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {
+ --#{$prefix}#{$css-variable-name}: #{$value};
+ }
+ }
+ } @else {
+ .#{$property-class + $infix + $property-class-modifier} {
+ @each $property in $properties {
+ @if $is-local-vars {
+ @each $local-var, $variable in $is-local-vars {
+ --#{$prefix}#{$local-var}: #{$variable};
+ }
+ }
+ #{$property}: $value if($enable-important-utilities, !important, null);
+ }
+ }
+
+ @each $pseudo in $state {
+ .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {
+ @each $property in $properties {
+ @if $is-local-vars {
+ @each $local-var, $variable in $is-local-vars {
+ --#{$prefix}#{$local-var}: #{$variable};
+ }
+ }
+ #{$property}: $value if($enable-important-utilities, !important, null);
+ }
+ }
+ }
+ }
+
+ @if $is-rtl == false {
+ /* rtl:end:remove */
+ }
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_visually-hidden.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_visually-hidden.scss
new file mode 100644
index 0000000000..4fc7f49d69
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/mixins/_visually-hidden.scss
@@ -0,0 +1,29 @@
+// stylelint-disable declaration-no-important
+
+// Hide content visually while keeping it accessible to assistive technologies
+//
+// See: https://www.a11yproject.com/posts/2013-01-11-how-to-hide-content/
+// See: https://kittygiraudel.com/2016/10/13/css-hide-and-seek/
+
+@mixin visually-hidden() {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important; // Fix for https://github.com/twbs/bootstrap/issues/25686
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
+// Use to only display content when it's focused, or one of its child elements is focused
+// (i.e. when focus is within the element/container that the class was applied to)
+//
+// Useful for "Skip to main content" links; see https://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1
+
+@mixin visually-hidden-focusable() {
+ &:not(:focus):not(:focus-within) {
+ @include visually-hidden();
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/utilities/_api.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/utilities/_api.scss
new file mode 100644
index 0000000000..62e1d398e3
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/utilities/_api.scss
@@ -0,0 +1,47 @@
+// Loop over each breakpoint
+@each $breakpoint in map-keys($grid-breakpoints) {
+
+ // Generate media query if needed
+ @include media-breakpoint-up($breakpoint) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+
+ // Loop over each utility property
+ @each $key, $utility in $utilities {
+ // The utility can be disabled with `false`, thus check if the utility is a map first
+ // Only proceed if responsive media queries are enabled or if it's the base media query
+ @if type-of($utility) == "map" and (map-get($utility, responsive) or $infix == "") {
+ @include generate-utility($utility, $infix);
+ }
+ }
+ }
+}
+
+// RFS rescaling
+@media (min-width: $rfs-mq-value) {
+ @each $breakpoint in map-keys($grid-breakpoints) {
+ $infix: breakpoint-infix($breakpoint, $grid-breakpoints);
+
+ @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {
+ // Loop over each utility property
+ @each $key, $utility in $utilities {
+ // The utility can be disabled with `false`, thus check if the utility is a map first
+ // Only proceed if responsive media queries are enabled or if it's the base media query
+ @if type-of($utility) == "map" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == "") {
+ @include generate-utility($utility, $infix, true);
+ }
+ }
+ }
+ }
+}
+
+
+// Print utilities
+@media print {
+ @each $key, $utility in $utilities {
+ // The utility can be disabled with `false`, thus check if the utility is a map first
+ // Then check if the utility needs print styles
+ @if type-of($utility) == "map" and map-get($utility, print) == true {
+ @include generate-utility($utility, "-print");
+ }
+ }
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/bootstrap/vendor/_rfs.scss b/csit.infra.dash/app/cdash/static/sass/bootstrap/vendor/_rfs.scss
new file mode 100644
index 0000000000..7e9a6c7a8a
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/bootstrap/vendor/_rfs.scss
@@ -0,0 +1,354 @@
+// stylelint-disable property-blacklist, scss/dollar-variable-default
+
+// SCSS RFS mixin
+//
+// Automated responsive values for font sizes, paddings, margins and much more
+//
+// Licensed under MIT (https://github.com/twbs/rfs/blob/main/LICENSE)
+
+// Configuration
+
+// Base value
+$rfs-base-value: 1.25rem !default;
+$rfs-unit: rem !default;
+
+@if $rfs-unit != rem and $rfs-unit != px {
+ @error "`#{$rfs-unit}` is not a valid unit for $rfs-unit. Use `px` or `rem`.";
+}
+
+// Breakpoint at where values start decreasing if screen width is smaller
+$rfs-breakpoint: 1200px !default;
+$rfs-breakpoint-unit: px !default;
+
+@if $rfs-breakpoint-unit != px and $rfs-breakpoint-unit != em and $rfs-breakpoint-unit != rem {
+ @error "`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.";
+}
+
+// Resize values based on screen height and width
+$rfs-two-dimensional: false !default;
+
+// Factor of decrease
+$rfs-factor: 10 !default;
+
+@if type-of($rfs-factor) != number or $rfs-factor <= 1 {
+ @error "`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.";
+}
+
+// Mode. Possibilities: "min-media-query", "max-media-query"
+$rfs-mode: min-media-query !default;
+
+// Generate enable or disable classes. Possibilities: false, "enable" or "disable"
+$rfs-class: false !default;
+
+// 1 rem = $rfs-rem-value px
+$rfs-rem-value: 16 !default;
+
+// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14
+$rfs-safari-iframe-resize-bug-fix: false !default;
+
+// Disable RFS by setting $enable-rfs to false
+$enable-rfs: true !default;
+
+// Cache $rfs-base-value unit
+$rfs-base-value-unit: unit($rfs-base-value);
+
+@function divide($dividend, $divisor, $precision: 10) {
+ $sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);
+ $dividend: abs($dividend);
+ $divisor: abs($divisor);
+ @if $dividend == 0 {
+ @return 0;
+ }
+ @if $divisor == 0 {
+ @error "Cannot divide by 0";
+ }
+ $remainder: $dividend;
+ $result: 0;
+ $factor: 10;
+ @while ($remainder > 0 and $precision >= 0) {
+ $quotient: 0;
+ @while ($remainder >= $divisor) {
+ $remainder: $remainder - $divisor;
+ $quotient: $quotient + 1;
+ }
+ $result: $result * 10 + $quotient;
+ $factor: $factor * .1;
+ $remainder: $remainder * 10;
+ $precision: $precision - 1;
+ @if ($precision < 0 and $remainder >= $divisor * 5) {
+ $result: $result + 1;
+ }
+ }
+ $result: $result * $factor * $sign;
+ $dividend-unit: unit($dividend);
+ $divisor-unit: unit($divisor);
+ $unit-map: (
+ "px": 1px,
+ "rem": 1rem,
+ "em": 1em,
+ "%": 1%
+ );
+ @if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {
+ $result: $result * map-get($unit-map, $dividend-unit);
+ }
+ @return $result;
+}
+
+// Remove px-unit from $rfs-base-value for calculations
+@if $rfs-base-value-unit == px {
+ $rfs-base-value: divide($rfs-base-value, $rfs-base-value * 0 + 1);
+}
+@else if $rfs-base-value-unit == rem {
+ $rfs-base-value: divide($rfs-base-value, divide($rfs-base-value * 0 + 1, $rfs-rem-value));
+}
+
+// Cache $rfs-breakpoint unit to prevent multiple calls
+$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);
+
+// Remove unit from $rfs-breakpoint for calculations
+@if $rfs-breakpoint-unit-cache == px {
+ $rfs-breakpoint: divide($rfs-breakpoint, $rfs-breakpoint * 0 + 1);
+}
+@else if $rfs-breakpoint-unit-cache == rem or $rfs-breakpoint-unit-cache == "em" {
+ $rfs-breakpoint: divide($rfs-breakpoint, divide($rfs-breakpoint * 0 + 1, $rfs-rem-value));
+}
+
+// Calculate the media query value
+$rfs-mq-value: if($rfs-breakpoint-unit == px, #{$rfs-breakpoint}px, #{divide($rfs-breakpoint, $rfs-rem-value)}#{$rfs-breakpoint-unit});
+$rfs-mq-property-width: if($rfs-mode == max-media-query, max-width, min-width);
+$rfs-mq-property-height: if($rfs-mode == max-media-query, max-height, min-height);
+
+// Internal mixin used to determine which media query needs to be used
+@mixin _rfs-media-query {
+ @if $rfs-two-dimensional {
+ @if $rfs-mode == max-media-query {
+ @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}), (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {
+ @content;
+ }
+ }
+ @else {
+ @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) and (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {
+ @content;
+ }
+ }
+ }
+ @else {
+ @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) {
+ @content;
+ }
+ }
+}
+
+// Internal mixin that adds disable classes to the selector if needed.
+@mixin _rfs-rule {
+ @if $rfs-class == disable and $rfs-mode == max-media-query {
+ // Adding an extra class increases specificity, which prevents the media query to override the property
+ &,
+ .disable-rfs &,
+ &.disable-rfs {
+ @content;
+ }
+ }
+ @else if $rfs-class == enable and $rfs-mode == min-media-query {
+ .enable-rfs &,
+ &.enable-rfs {
+ @content;
+ }
+ }
+ @else {
+ @content;
+ }
+}
+
+// Internal mixin that adds enable classes to the selector if needed.
+@mixin _rfs-media-query-rule {
+
+ @if $rfs-class == enable {
+ @if $rfs-mode == min-media-query {
+ @content;
+ }
+
+ @include _rfs-media-query {
+ .enable-rfs &,
+ &.enable-rfs {
+ @content;
+ }
+ }
+ }
+ @else {
+ @if $rfs-class == disable and $rfs-mode == min-media-query {
+ .disable-rfs &,
+ &.disable-rfs {
+ @content;
+ }
+ }
+ @include _rfs-media-query {
+ @content;
+ }
+ }
+}
+
+// Helper function to get the formatted non-responsive value
+@function rfs-value($values) {
+ // Convert to list
+ $values: if(type-of($values) != list, ($values,), $values);
+
+ $val: '';
+
+ // Loop over each value and calculate value
+ @each $value in $values {
+ @if $value == 0 {
+ $val: $val + ' 0';
+ }
+ @else {
+ // Cache $value unit
+ $unit: if(type-of($value) == "number", unit($value), false);
+
+ @if $unit == px {
+ // Convert to rem if needed
+ $val: $val + ' ' + if($rfs-unit == rem, #{divide($value, $value * 0 + $rfs-rem-value)}rem, $value);
+ }
+ @else if $unit == rem {
+ // Convert to px if needed
+ $val: $val + ' ' + if($rfs-unit == px, #{divide($value, $value * 0 + 1) * $rfs-rem-value}px, $value);
+ }
+ @else {
+ // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value
+ $val: $val + ' ' + $value;
+ }
+ }
+ }
+
+ // Remove first space
+ @return unquote(str-slice($val, 2));
+}
+
+// Helper function to get the responsive value calculated by RFS
+@function rfs-fluid-value($values) {
+ // Convert to list
+ $values: if(type-of($values) != list, ($values,), $values);
+
+ $val: '';
+
+ // Loop over each value and calculate value
+ @each $value in $values {
+ @if $value == 0 {
+ $val: $val + ' 0';
+ }
+
+ @else {
+ // Cache $value unit
+ $unit: if(type-of($value) == "number", unit($value), false);
+
+ // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value
+ @if not $unit or $unit != px and $unit != rem {
+ $val: $val + ' ' + $value;
+ }
+
+ @else {
+ // Remove unit from $value for calculations
+ $value: divide($value, $value * 0 + if($unit == px, 1, divide(1, $rfs-rem-value)));
+
+ // Only add the media query if the value is greater than the minimum value
+ @if abs($value) <= $rfs-base-value or not $enable-rfs {
+ $val: $val + ' ' + if($rfs-unit == rem, #{divide($value, $rfs-rem-value)}rem, #{$value}px);
+ }
+ @else {
+ // Calculate the minimum value
+ $value-min: $rfs-base-value + divide(abs($value) - $rfs-base-value, $rfs-factor);
+
+ // Calculate difference between $value and the minimum value
+ $value-diff: abs($value) - $value-min;
+
+ // Base value formatting
+ $min-width: if($rfs-unit == rem, #{divide($value-min, $rfs-rem-value)}rem, #{$value-min}px);
+
+ // Use negative value if needed
+ $min-width: if($value < 0, -$min-width, $min-width);
+
+ // Use `vmin` if two-dimensional is enabled
+ $variable-unit: if($rfs-two-dimensional, vmin, vw);
+
+ // Calculate the variable width between 0 and $rfs-breakpoint
+ $variable-width: #{divide($value-diff * 100, $rfs-breakpoint)}#{$variable-unit};
+
+ // Return the calculated value
+ $val: $val + ' calc(' + $min-width + if($value < 0, ' - ', ' + ') + $variable-width + ')';
+ }
+ }
+ }
+ }
+
+ // Remove first space
+ @return unquote(str-slice($val, 2));
+}
+
+// RFS mixin
+@mixin rfs($values, $property: font-size) {
+ @if $values != null {
+ $val: rfs-value($values);
+ $fluidVal: rfs-fluid-value($values);
+
+ // Do not print the media query if responsive & non-responsive values are the same
+ @if $val == $fluidVal {
+ #{$property}: $val;
+ }
+ @else {
+ @include _rfs-rule {
+ #{$property}: if($rfs-mode == max-media-query, $val, $fluidVal);
+
+ // Include safari iframe resize fix if needed
+ min-width: if($rfs-safari-iframe-resize-bug-fix, (0 * 1vw), null);
+ }
+
+ @include _rfs-media-query-rule {
+ #{$property}: if($rfs-mode == max-media-query, $fluidVal, $val);
+ }
+ }
+ }
+}
+
+// Shorthand helper mixins
+@mixin font-size($value) {
+ @include rfs($value);
+}
+
+@mixin padding($value) {
+ @include rfs($value, padding);
+}
+
+@mixin padding-top($value) {
+ @include rfs($value, padding-top);
+}
+
+@mixin padding-right($value) {
+ @include rfs($value, padding-right);
+}
+
+@mixin padding-bottom($value) {
+ @include rfs($value, padding-bottom);
+}
+
+@mixin padding-left($value) {
+ @include rfs($value, padding-left);
+}
+
+@mixin margin($value) {
+ @include rfs($value, margin);
+}
+
+@mixin margin-top($value) {
+ @include rfs($value, margin-top);
+}
+
+@mixin margin-right($value) {
+ @include rfs($value, margin-right);
+}
+
+@mixin margin-bottom($value) {
+ @include rfs($value, margin-bottom);
+}
+
+@mixin margin-left($value) {
+ @include rfs($value, margin-left);
+}
diff --git a/csit.infra.dash/app/cdash/static/sass/lux.scss b/csit.infra.dash/app/cdash/static/sass/lux.scss
new file mode 100644
index 0000000000..f32f93c5d4
--- /dev/null
+++ b/csit.infra.dash/app/cdash/static/sass/lux.scss
@@ -0,0 +1,9 @@
+// Your variable overrides go here, e.g.:
+// $h1-font-size: 3rem;
+
+$accordion-padding-y: 0.5rem;
+$accordion-padding-x: 0.65rem;
+
+@import "variables";
+@import "bootstrap/bootstrap";
+@import "bootswatch"; \ No newline at end of file
diff --git a/csit.infra.dash/app/cdash/stats/__init__.py b/csit.infra.dash/app/cdash/stats/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/stats/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/stats/graphs.py b/csit.infra.dash/app/cdash/stats/graphs.py
new file mode 100644
index 0000000000..4b25396680
--- /dev/null
+++ b/csit.infra.dash/app/cdash/stats/graphs.py
@@ -0,0 +1,129 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+"""
+
+import plotly.graph_objects as go
+import pandas as pd
+
+from ..utils.constants import Constants as C
+
+
+def select_data(data: pd.DataFrame, itm: str) -> pd.DataFrame:
+ """Select the data for graphs from the provided data frame.
+
+ :param data: Data frame with data for graphs.
+ :param itm: Item (in this case job name) which data will be selected from
+ the input data frame.
+ :type data: pandas.DataFrame
+ :type itm: str
+ :returns: A data frame with selected data.
+ :rtype: pandas.DataFrame
+ """
+
+ df = data.loc[(data["job"] == itm)].sort_values(
+ by="start_time", ignore_index=True)
+ df = df.dropna(subset=["duration", ])
+
+ return df
+
+
+def graph_statistics(df: pd.DataFrame, job: str, layout: dict) -> tuple:
+ """Generate graphs:
+ 1. Passed / failed tests,
+ 2. Job durations
+ with additional information shown in hover.
+
+ :param df: Data frame with input data.
+ :param job: The name of job which data will be presented in the graphs.
+ :param layout: Layout of plot.ly graph.
+ :type df: pandas.DataFrame
+ :type job: str
+ :type layout: dict
+ :returns: Tuple with two generated graphs (pased/failed tests and job
+ duration).
+ :rtype: tuple(plotly.graph_objects.Figure, plotly.graph_objects.Figure)
+ """
+
+ data = select_data(df, job)
+ if data.empty:
+ return None, None
+
+ hover = list()
+ for _, row in data.iterrows():
+ hover_itm = (
+ f"date: {row['start_time'].strftime('%Y-%m-%d %H:%M:%S')}<br>"
+ f"duration: "
+ f"{(int(row['duration']) // 3600):02d}:"
+ f"{((int(row['duration']) % 3600) // 60):02d}<br>"
+ f"passed: {row['passed']}<br>"
+ f"failed: {row['failed']}<br>"
+ f"{row['dut_type']}-ref: {row['dut_version']}<br>"
+ f"csit-ref: {row['job']}/{row['build']}<br>"
+ f"hosts: {', '.join(row['hosts'])}"
+ )
+ hover.append(hover_itm)
+
+ # Job durations:
+ fig_duration = go.Figure(
+ data=go.Scatter(
+ x=data["start_time"],
+ y=data["duration"],
+ name="Duration",
+ text=hover,
+ hoverinfo="text"
+ )
+ )
+
+ tickvals = [0, ]
+ step = max(data["duration"]) / 5
+ for i in range(5):
+ tickvals.append(int(step * (i + 1)))
+ layout_duration = layout.get("plot-stats-duration", dict())
+ if layout_duration:
+ layout_duration["yaxis"]["tickvals"] = tickvals
+ layout_duration["yaxis"]["ticktext"] = [
+ f"{(val // 3600):02d}:{((val % 3600) // 60):02d}" \
+ for val in tickvals
+ ]
+ fig_duration.update_layout(layout_duration)
+
+ # Passed / failed:
+ bar_width = C.STATS_BAR_WIDTH_WEEKLY \
+ if "weekly" in job else C.STATS_BAR_WIDTH_DAILY
+ fig_passed = go.Figure(
+ data=[
+ go.Bar(
+ x=data["start_time"],
+ y=data["passed"],
+ name="Passed",
+ hovertext=hover,
+ hoverinfo="text",
+ width=bar_width
+ ),
+ go.Bar(
+ x=data["start_time"],
+ y=data["failed"],
+ name="Failed",
+ hovertext=hover,
+ hoverinfo="text",
+ width=bar_width
+ )
+ ]
+ )
+ layout_pf = layout.get("plot-stats-passed", dict())
+ if layout_pf:
+ fig_passed.update_layout(layout_pf)
+
+ return fig_passed, fig_duration
diff --git a/csit.infra.dash/app/cdash/stats/layout.py b/csit.infra.dash/app/cdash/stats/layout.py
new file mode 100644
index 0000000000..655c61c078
--- /dev/null
+++ b/csit.infra.dash/app/cdash/stats/layout.py
@@ -0,0 +1,853 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Plotly Dash HTML layout override.
+"""
+
+import logging
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from flask import Flask
+from dash import dcc
+from dash import html
+from dash import callback_context, no_update
+from dash import Input, Output, State
+from dash.exceptions import PreventUpdate
+from yaml import load, FullLoader, YAMLError
+
+from ..utils.constants import Constants as C
+from ..utils.control_panel import ControlPanel
+from ..utils.utils import show_tooltip, gen_new_url, get_ttypes, get_cadences, \
+ get_test_beds, get_job, generate_options, set_job_params, navbar_trending
+from ..utils.url_processing import url_decode
+from .graphs import graph_statistics, select_data
+
+
+class Layout:
+ """The layout of the dash app and the callbacks.
+ """
+
+ def __init__(
+ self,
+ app: Flask,
+ data_stats: pd.DataFrame,
+ data_trending: pd.DataFrame,
+ html_layout_file: str,
+ graph_layout_file: str,
+ tooltip_file: str
+ ) -> None:
+ """Initialization:
+ - save the input parameters,
+ - read and pre-process the data,
+ - prepare data for the control panel,
+ - read HTML layout file,
+ - read tooltips from the tooltip file.
+
+ :param app: Flask application running the dash application.
+ :param data_stats: Pandas dataframe with staistical data.
+ :param data_trending: Pandas dataframe with trending data.
+ :param html_layout_file: Path and name of the file specifying the HTML
+ layout of the dash application.
+ :param graph_layout_file: Path and name of the file with layout of
+ plot.ly graphs.
+ :param tooltip_file: Path and name of the yaml file specifying the
+ tooltips.
+ :type app: Flask
+ :type data_stats: pandas.DataFrame
+ :type data_trending: pandas.DataFrame
+ :type html_layout_file: str
+ :type graph_layout_file: str
+ :type tooltip_file: str
+ """
+
+ # Inputs
+ self._app = app
+ self._html_layout_file = html_layout_file
+ self._graph_layout_file = graph_layout_file
+ self._tooltip_file = tooltip_file
+
+ # Pre-process the data:
+ data_stats = data_stats[~data_stats.job.str.contains("-verify-")]
+ data_stats = data_stats[~data_stats.job.str.contains("-coverage-")]
+ data_stats = data_stats[~data_stats.job.str.contains("-iterative-")]
+ data_stats = data_stats[["job", "build", "start_time", "duration"]]
+
+ jobs = sorted(list(data_stats["job"].unique()))
+ d_job_info = {
+ "job": list(),
+ "dut": list(),
+ "ttype": list(),
+ "cadence": list(),
+ "tbed": list()
+ }
+ for job in jobs:
+ lst_job = job.split("-")
+ d_job_info["job"].append(job)
+ d_job_info["dut"].append(lst_job[1])
+ d_job_info["ttype"].append(lst_job[3])
+ d_job_info["cadence"].append(lst_job[4])
+ d_job_info["tbed"].append("-".join(lst_job[-2:]))
+ self._job_info = pd.DataFrame.from_dict(d_job_info)
+
+ self._default = set_job_params(self._job_info, C.STATS_DEFAULT_JOB)
+
+ tst_info = {
+ "job": list(),
+ "build": list(),
+ "dut_type": list(),
+ "dut_version": list(),
+ "hosts": list(),
+ "passed": list(),
+ "failed": list(),
+ "lst_failed": list()
+ }
+ for job in jobs:
+ df_job = data_trending.loc[(data_trending["job"] == job)]
+ builds = df_job["build"].unique()
+ for build in builds:
+ df_build = df_job.loc[(df_job["build"] == build)]
+ tst_info["job"].append(job)
+ tst_info["build"].append(build)
+ tst_info["dut_type"].append(df_build["dut_type"].iloc[-1])
+ tst_info["dut_version"].append(df_build["dut_version"].iloc[-1])
+ tst_info["hosts"].append(df_build["hosts"].iloc[-1])
+ try:
+ passed = df_build.value_counts(subset="passed")[True]
+ except KeyError:
+ passed = 0
+ try:
+ failed = df_build.value_counts(subset="passed")[False]
+ failed_tests = df_build.loc[(df_build["passed"] == False)]\
+ ["test_id"].to_list()
+ l_failed = list()
+ for tst in failed_tests:
+ lst_tst = tst.split(".")
+ suite = lst_tst[-2].replace("2n1l-", "").\
+ replace("1n1l-", "").replace("2n-", "")
+ l_failed.append(f"{suite.split('-')[0]}-{lst_tst[-1]}")
+ except KeyError:
+ failed = 0
+ l_failed = list()
+ tst_info["passed"].append(passed)
+ tst_info["failed"].append(failed)
+ tst_info["lst_failed"].append(sorted(l_failed))
+
+ self._data = data_stats.merge(pd.DataFrame.from_dict(tst_info))
+
+ # Read from files:
+ self._html_layout = str()
+ self._graph_layout = None
+ self._tooltips = dict()
+
+ try:
+ with open(self._html_layout_file, "r") as file_read:
+ self._html_layout = file_read.read()
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._html_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._graph_layout_file, "r") as file_read:
+ self._graph_layout = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._graph_layout_file}\n"
+ f"{err}"
+ )
+ except YAMLError as err:
+ raise RuntimeError(
+ f"An error occurred while parsing the specification file "
+ f"{self._graph_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._tooltip_file, "r") as file_read:
+ self._tooltips = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ logging.warning(
+ f"Not possible to open the file {self._tooltip_file}\n{err}"
+ )
+ except YAMLError as err:
+ logging.warning(
+ f"An error occurred while parsing the specification file "
+ f"{self._tooltip_file}\n{err}"
+ )
+
+ # Control panel partameters and their default values.
+ self._cp_default = {
+ "ri-ttypes-options": self._default["ttypes"],
+ "ri-cadences-options": self._default["cadences"],
+ "dd-tbeds-options": self._default["tbeds"],
+ "ri-duts-value": self._default["dut"],
+ "ri-ttypes-value": self._default["ttype"],
+ "ri-cadences-value": self._default["cadence"],
+ "dd-tbeds-value": self._default["tbed"],
+ "al-job-children": html.A(
+ self._default["job"],
+ href=f"{C.URL_JENKINS}{self._default['job']}",
+ target="_blank"
+ )
+ }
+
+ # Callbacks:
+ if self._app is not None and hasattr(self, "callbacks"):
+ self.callbacks(self._app)
+
+ @property
+ def html_layout(self) -> dict:
+ return self._html_layout
+
+ def add_content(self):
+ """Top level method which generated the web page.
+
+ It generates:
+ - Store for user input data,
+ - Navigation bar,
+ - Main area with control panel and ploting area.
+
+ If no HTML layout is provided, an error message is displayed instead.
+
+ :returns: The HTML div with the whole page.
+ :rtype: html.Div
+ """
+
+ if self.html_layout:
+ return html.Div(
+ id="div-main",
+ className="small",
+ children=[
+ dcc.Store(id="control-panel"),
+ dcc.Location(id="url", refresh=False),
+ dbc.Row(
+ id="row-navbar",
+ class_name="g-0",
+ children=[navbar_trending((False, False, True, False))]
+ ),
+ dbc.Spinner(
+ dbc.Offcanvas(
+ class_name="w-50",
+ id="offcanvas-metadata",
+ title="Detailed Information",
+ placement="end",
+ is_open=False,
+ children=[
+ dbc.Row(id="row-metadata")
+ ]
+ )
+ ),
+ dbc.Row(
+ id="row-main",
+ class_name="g-0",
+ children=[
+ self._add_ctrl_col(),
+ self._add_plotting_col()
+ ]
+ ),
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-documentation",
+ title="Documentation",
+ placement="end",
+ is_open=False,
+ children=html.Iframe(
+ src=C.URL_DOC_TRENDING,
+ width="100%",
+ height="100%"
+ )
+ )
+ ]
+ )
+ else:
+ return html.Div(
+ id="div-main-error",
+ children=[
+ dbc.Alert(
+ [
+ "An Error Occured",
+ ],
+ color="danger"
+ )
+ ]
+ )
+
+ def _add_ctrl_col(self) -> dbc.Col:
+ """Add column with controls. It is placed on the left side.
+
+ :returns: Column with the control panel.
+ :rtype: dbc.Col
+ """
+ return dbc.Col([
+ html.Div(
+ children=self._add_ctrl_panel(),
+ className="sticky-top"
+ )
+ ])
+
+ def _add_plotting_col(self) -> dbc.Col:
+ """Add column with plots and tables. It is placed on the right side.
+
+ :returns: Column with tables.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(
+ id="col-plotting-area",
+ children=[
+ dbc.Spinner(
+ children=[
+ dbc.Row(
+ id="plotting-area",
+ class_name="g-0 p-0",
+ children=[
+ C.PLACEHOLDER
+ ]
+ )
+ ]
+ )
+ ],
+ width=9
+ )
+
+ def _add_ctrl_panel(self) -> dbc.Row:
+ """Add control panel.
+
+ :returns: Control panel.
+ :rtype: dbc.Row
+ """
+ return [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-dut",
+ "DUT"
+ )),
+ dbc.RadioItems(
+ id="ri-duts",
+ inline=True,
+ value=self._default["dut"],
+ options=self._default["duts"],
+ class_name="form-control"
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-ttype",
+ "Test Type"
+ )),
+ dbc.RadioItems(
+ id="ri-ttypes",
+ inline=True,
+ value=self._default["ttype"],
+ options=self._default["ttypes"],
+ class_name="form-control"
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-cadence",
+ "Cadence"
+ )),
+ dbc.RadioItems(
+ id="ri-cadences",
+ inline=True,
+ value=self._default["cadence"],
+ options=self._default["cadences"],
+ class_name="form-control"
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-tbed",
+ "Test Bed"
+ )),
+ dbc.Select(
+ id="dd-tbeds",
+ placeholder="Select a test bed...",
+ value=self._default["tbed"],
+ options=self._default["tbeds"]
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.Alert(
+ id="al-job",
+ color="info",
+ children=self._default["job"]
+ )
+ ]
+ )
+ ]
+
+ def _get_plotting_area(
+ self,
+ job: str,
+ url: str
+ ) -> list:
+ """Generate the plotting area with all its content.
+
+ :param job: The job which data will be displayed.
+ :param url: URL to be displayed in the modal window.
+ :type job: str
+ :type url: str
+ :returns: List of rows with elements to be displayed in the plotting
+ area.
+ :rtype: list
+ """
+
+ figs = graph_statistics(self._data, job, self._graph_layout)
+
+ if not figs[0]:
+ return C.PLACEHOLDER
+
+ return [
+ dbc.Row(
+ id="row-graph-passed",
+ class_name="g-0 p-1",
+ children=[
+ dcc.Graph(
+ id="graph-passed",
+ figure=figs[0]
+ )
+ ]
+ ),
+ dbc.Row(
+ id="row-graph-duration",
+ class_name="g-0 p-1",
+ children=[
+ dcc.Graph(
+ id="graph-duration",
+ figure=figs[1]
+ )
+ ]
+ ),
+ dbc.Row(
+ [
+ dbc.Col([html.Div(
+ [
+ dbc.Button(
+ id="plot-btn-url",
+ children="Show URL",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(dbc.ModalTitle("URL")),
+ dbc.ModalBody(url)
+ ],
+ id="plot-mod-url",
+ size="xl",
+ is_open=False,
+ scrollable=True
+ ),
+ dbc.Button(
+ id="plot-btn-download",
+ children="Download Data",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dcc.Download(id="download-stats-data")
+ ],
+ className=\
+ "d-grid gap-0 d-md-flex justify-content-md-end"
+ )])
+ ],
+ class_name="g-0 p-0"
+ )
+ ]
+
+ def callbacks(self, app):
+ """Callbacks for the whole application.
+
+ :param app: The application.
+ :type app: Flask
+ """
+
+ @app.callback(
+ Output("control-panel", "data"), # Store
+ Output("plotting-area", "children"),
+ Output("ri-ttypes", "options"),
+ Output("ri-cadences", "options"),
+ Output("dd-tbeds", "options"),
+ Output("ri-duts", "value"),
+ Output("ri-ttypes", "value"),
+ Output("ri-cadences", "value"),
+ Output("dd-tbeds", "value"),
+ Output("al-job", "children"),
+ State("control-panel", "data"), # Store
+ Input("ri-duts", "value"),
+ Input("ri-ttypes", "value"),
+ Input("ri-cadences", "value"),
+ Input("dd-tbeds", "value"),
+ Input("url", "href")
+ )
+ def _update_ctrl_panel(cp_data: dict, dut: str, ttype: str,
+ cadence: str, tbed: str, href: str) -> tuple:
+ """Update the application when the event is detected.
+
+ :param cp_data: Current status of the control panel stored in
+ browser.
+ :param dut: Input - DUT name.
+ :param ttype: Input - Test type.
+ :param cadence: Input - The cadence of the job.
+ :param tbed: Input - The test bed.
+ :param href: Input - The URL provided by the browser.
+ :type cp_data: dict
+ :type dut: str
+ :type ttype: str
+ :type cadence: str
+ :type tbed: str
+ :type href: str
+ :returns: New values for web page elements.
+ :rtype: tuple
+ """
+
+ ctrl_panel = ControlPanel(self._cp_default, cp_data)
+
+ # Parse the url:
+ parsed_url = url_decode(href)
+ if parsed_url:
+ url_params = parsed_url["params"]
+ else:
+ url_params = None
+
+ trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
+ if trigger_id == "ri-duts":
+ ttype_opts = generate_options(get_ttypes(self._job_info, dut))
+ ttype_val = ttype_opts[0]["value"]
+ cad_opts = generate_options(get_cadences(
+ self._job_info, dut, ttype_val))
+ cad_val = cad_opts[0]["value"]
+ tbed_opts = generate_options(get_test_beds(
+ self._job_info, dut, ttype_val, cad_val))
+ tbed_val = tbed_opts[0]["value"]
+ ctrl_panel.set({
+ "ri-duts-value": dut,
+ "ri-ttypes-options": ttype_opts,
+ "ri-ttypes-value": ttype_val,
+ "ri-cadences-options": cad_opts,
+ "ri-cadences-value": cad_val,
+ "dd-tbeds-options": tbed_opts,
+ "dd-tbeds-value": tbed_val
+ })
+ elif trigger_id == "ri-ttypes":
+ cad_opts = generate_options(get_cadences(
+ self._job_info, ctrl_panel.get("ri-duts-value"), ttype))
+ cad_val = cad_opts[0]["value"]
+ tbed_opts = generate_options(get_test_beds(
+ self._job_info, ctrl_panel.get("ri-duts-value"), ttype,
+ cad_val))
+ tbed_val = tbed_opts[0]["value"]
+ ctrl_panel.set({
+ "ri-ttypes-value": ttype,
+ "ri-cadences-options": cad_opts,
+ "ri-cadences-value": cad_val,
+ "dd-tbeds-options": tbed_opts,
+ "dd-tbeds-value": tbed_val
+ })
+ elif trigger_id == "ri-cadences":
+ tbed_opts = generate_options(get_test_beds(
+ self._job_info, ctrl_panel.get("ri-duts-value"),
+ ctrl_panel.get("ri-ttypes-value"), cadence))
+ tbed_val = tbed_opts[0]["value"]
+ ctrl_panel.set({
+ "ri-cadences-value": cadence,
+ "dd-tbeds-options": tbed_opts,
+ "dd-tbeds-value": tbed_val
+ })
+ elif trigger_id == "dd-tbeds":
+ ctrl_panel.set({
+ "dd-tbeds-value": tbed
+ })
+ elif trigger_id == "url":
+ if url_params:
+ new_job = url_params.get("job", list())[0]
+ if new_job:
+ job_params = set_job_params(self._job_info, new_job)
+ ctrl_panel = ControlPanel(
+ {
+ "ri-ttypes-options": job_params["ttypes"],
+ "ri-cadences-options": job_params["cadences"],
+ "dd-tbeds-options": job_params["tbeds"],
+ "ri-duts-value": job_params["dut"],
+ "ri-ttypes-value": job_params["ttype"],
+ "ri-cadences-value": job_params["cadence"],
+ "dd-tbeds-value": job_params["tbed"],
+ "al-job-children": html.A(
+ self._default["job"],
+ href=(
+ f"{C.URL_JENKINS}"
+ f"{self._default['job']}"
+ ),
+ target="_blank"
+ )
+ },
+ None
+ )
+ else:
+ ctrl_panel = ControlPanel(self._cp_default, cp_data)
+
+ job = get_job(
+ self._job_info,
+ ctrl_panel.get("ri-duts-value"),
+ ctrl_panel.get("ri-ttypes-value"),
+ ctrl_panel.get("ri-cadences-value"),
+ ctrl_panel.get("dd-tbeds-value")
+ )
+
+ ctrl_panel.set(
+ {
+ "al-job-children": html.A(
+ job,
+ href=f"{C.URL_JENKINS}{job}",
+ target="_blank"
+ )
+ }
+ )
+ plotting_area = self._get_plotting_area(
+ job,
+ gen_new_url(parsed_url, {"job": job})
+ )
+
+ ret_val = [
+ ctrl_panel.panel,
+ plotting_area
+ ]
+ ret_val.extend(ctrl_panel.values)
+ return ret_val
+
+ @app.callback(
+ Output("plot-mod-url", "is_open"),
+ [Input("plot-btn-url", "n_clicks")],
+ [State("plot-mod-url", "is_open")],
+ )
+ def toggle_plot_mod_url(n, is_open):
+ """Toggle the modal window with url.
+ """
+ if n:
+ return not is_open
+ return is_open
+
+ @app.callback(
+ Output("download-stats-data", "data"),
+ State("control-panel", "data"), # Store
+ Input("plot-btn-download", "n_clicks"),
+ prevent_initial_call=True
+ )
+ def _download_data(cp_data: dict, n_clicks: int):
+ """Download the data
+
+ :param cp_data: Current status of the control panel stored in
+ browser.
+ :param n_clicks: Number of clicks on the button "Download".
+ :type cp_data: dict
+ :type n_clicks: int
+ :returns: dict of data frame content (base64 encoded) and meta data
+ used by the Download component.
+ :rtype: dict
+ """
+ if not n_clicks:
+ raise PreventUpdate
+
+ ctrl_panel = ControlPanel(self._cp_default, cp_data)
+
+ job = get_job(
+ self._job_info,
+ ctrl_panel.get("ri-duts-value"),
+ ctrl_panel.get("ri-ttypes-value"),
+ ctrl_panel.get("ri-cadences-value"),
+ ctrl_panel.get("dd-tbeds-value")
+ )
+
+ data = select_data(self._data, job)
+ data = data.drop(columns=["job", ])
+
+ return dcc.send_data_frame(
+ data.T.to_csv, f"{job}-{C.STATS_DOWNLOAD_FILE_NAME}")
+
+ @app.callback(
+ Output("row-metadata", "children"),
+ Output("offcanvas-metadata", "is_open"),
+ Input("graph-passed", "clickData"),
+ Input("graph-duration", "clickData"),
+ prevent_initial_call=True
+ )
+ def _show_metadata_from_graphs(
+ passed_data: dict, duration_data: dict) -> tuple:
+ """Generates the data for the offcanvas displayed when a particular
+ point in a graph is clicked on.
+
+ :param passed_data: The data from the clicked point in the graph
+ displaying the pass/fail data.
+ :param duration_data: The data from the clicked point in the graph
+ displaying the duration data.
+ :type passed_data: dict
+ :type duration data: dict
+ :returns: The data to be displayed on the offcanvas (job statistics
+ and the list of failed tests) and the information to show the
+ offcanvas.
+ :rtype: tuple(list, bool)
+ """
+
+ if not (passed_data or duration_data):
+ raise PreventUpdate
+
+ metadata = no_update
+ open_canvas = False
+ title = "Job Statistics"
+ trigger_id = callback_context.triggered[0]["prop_id"].split(".")[0]
+ if trigger_id == "graph-passed":
+ graph_data = passed_data["points"][0].get("hovertext", "")
+ elif trigger_id == "graph-duration":
+ graph_data = duration_data["points"][0].get("text", "")
+ if graph_data:
+ lst_graph_data = graph_data.split("<br>")
+
+ # Prepare list of failed tests:
+ job = str()
+ build = str()
+ for itm in lst_graph_data:
+ if "csit-ref:" in itm:
+ job, build = itm.split(" ")[-1].split("/")
+ break
+ if job and build:
+ fail_tests = self._data.loc[
+ (self._data["job"] == job) &
+ (self._data["build"] == build)
+ ]["lst_failed"].values[0]
+ if not fail_tests:
+ fail_tests = None
+ else:
+ fail_tests = None
+
+ # Create the content of the offcanvas:
+ list_group_items = list()
+ for itm in lst_graph_data:
+ lst_itm = itm.split(": ")
+ if lst_itm[0] == "csit-ref":
+ list_group_item = dbc.ListGroupItem([
+ dbc.Badge(lst_itm[0]),
+ html.A(
+ lst_itm[1],
+ href=f"{C.URL_LOGS}{lst_itm[1]}",
+ target="_blank"
+ )
+ ])
+ else:
+ list_group_item = dbc.ListGroupItem([
+ dbc.Badge(lst_itm[0]),
+ lst_itm[1]
+ ])
+ list_group_items.append(list_group_item)
+ metadata = [
+ dbc.Card(
+ class_name="gy-2 p-0",
+ children=[
+ dbc.CardHeader([
+ dcc.Clipboard(
+ target_id="metadata",
+ title="Copy",
+ style={"display": "inline-block"}
+ ),
+ title
+ ]),
+ dbc.CardBody(
+ dbc.ListGroup(list_group_items, flush=True),
+ id="metadata",
+ class_name="p-0"
+ )
+ ]
+ )
+ ]
+
+ if fail_tests is not None:
+ metadata.append(
+ dbc.Card(
+ class_name="gy-2 p-0",
+ children=[
+ dbc.CardHeader(
+ f"List of Failed Tests ({len(fail_tests)})"
+ ),
+ dbc.CardBody(
+ id="failed-tests",
+ class_name="p-0",
+ children=[dbc.ListGroup(
+ children=[
+ dbc.ListGroupItem(x) \
+ for x in fail_tests
+ ],
+ flush=True),
+ ]
+ )
+ ]
+ )
+ )
+
+ open_canvas = True
+
+ return metadata, open_canvas
+
+ @app.callback(
+ Output("offcanvas-documentation", "is_open"),
+ Input("btn-documentation", "n_clicks"),
+ State("offcanvas-documentation", "is_open")
+ )
+ def toggle_offcanvas_documentation(n_clicks, is_open):
+ if n_clicks:
+ return not is_open
+ return is_open
diff --git a/csit.infra.dash/app/cdash/stats/layout.yaml b/csit.infra.dash/app/cdash/stats/layout.yaml
new file mode 100644
index 0000000000..488654640f
--- /dev/null
+++ b/csit.infra.dash/app/cdash/stats/layout.yaml
@@ -0,0 +1,79 @@
+plot-stats-passed:
+ autosize: True
+ showlegend: False
+ yaxis:
+ showticklabels: True
+ title: "Number of Passed / Failed Tests"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ rangemode: "tozero"
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 5
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ barmode: "stack"
+ hoverlabel:
+ namelength: -1
+
+plot-stats-duration:
+ autosize: True
+ showlegend: False
+ yaxis:
+ showticklabels: True
+ title: "Duration [hh:mm]"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickmode: "array"
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ rangemode: "tozero"
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 5
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
diff --git a/csit.infra.dash/app/cdash/stats/stats.py b/csit.infra.dash/app/cdash/stats/stats.py
new file mode 100644
index 0000000000..0217a6edb6
--- /dev/null
+++ b/csit.infra.dash/app/cdash/stats/stats.py
@@ -0,0 +1,58 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Instantiate the Statistics Dash application.
+"""
+import dash
+import pandas as pd
+
+from ..utils.constants import Constants as C
+from .layout import Layout
+
+
+def init_stats(
+ server,
+ data_stats: pd.DataFrame,
+ data_trending: pd.DataFrame
+ ) -> dash.Dash:
+ """Create a Plotly Dash dashboard.
+
+ :param server: Flask server.
+ :param data_stats: Pandas dataframe with staistical data.
+ :param data_trending: Pandas dataframe with trending data.
+ :type server: Flask
+ :type data_stats: pandas.DataFrame
+ :type data_trending: pandas.DataFrame
+ :returns: Dash app server.
+ :rtype: Dash
+ """
+
+ dash_app = dash.Dash(
+ server=server,
+ routes_pathname_prefix=C.STATS_ROUTES_PATHNAME_PREFIX,
+ external_stylesheets=C.EXTERNAL_STYLESHEETS,
+ title=C.STATS_TITLE
+ )
+
+ layout = Layout(
+ app=dash_app,
+ data_stats=data_stats,
+ data_trending=data_trending,
+ html_layout_file=C.HTML_LAYOUT_FILE,
+ graph_layout_file=C.STATS_GRAPH_LAYOUT_FILE,
+ tooltip_file=C.TOOLTIP_FILE
+ )
+ dash_app.index_string = layout.html_layout
+ dash_app.layout = layout.add_content()
+
+ return dash_app.server
diff --git a/csit.infra.dash/app/cdash/templates/base_layout.jinja2 b/csit.infra.dash/app/cdash/templates/base_layout.jinja2
new file mode 100644
index 0000000000..7b0dadc5a0
--- /dev/null
+++ b/csit.infra.dash/app/cdash/templates/base_layout.jinja2
@@ -0,0 +1,71 @@
+{% extends "layout.jinja2" %}
+
+{% block content %}
+<div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column">
+
+ <header class="mb-auto">
+ <div>
+ <h3 class="float-md-start mb-0 text-white">
+ CSIT-Dash
+ </h3>
+ </div>
+ </header>
+
+ <main class="px-3">
+ <img class="d-block mx-auto mb-4" src="{{ url_for('static', filename='img/logo.svg') }}" alt="" width="72" height="57">
+ <h1 class="text-white">
+ {{ title }}
+ </h1>
+ <p class="lead">
+ {{ description }}
+ </p>
+ <p class="lead">
+ <p>
+ <a href="/trending/" class="btn btn-primary fw-bold w-25">
+ {{ trending_title }}
+ </a>
+ </p>
+ <p>
+ <a href="/report/" class="btn btn-primary fw-bold w-25">
+ {{ report_title }}
+ </a>
+ </p>
+ <p>
+ <a href="/comparisons/" class="btn btn-primary fw-bold w-25">
+ {{ comp_title }}
+ </a>
+ </p>
+ <p>
+ <p>
+ <a href="/coverage/" class="btn btn-primary fw-bold w-25">
+ {{ cov_title }}
+ </a>
+ </p>
+ <p>
+ <a href="/stats/" class="btn btn-primary fw-bold w-25">
+ {{ stats_title }}
+ </a>
+ </p>
+ <p>
+ <a href="/news/" class="btn btn-primary fw-bold w-25">
+ {{ news_title }}
+ </a>
+ </p>
+ <p>
+ <a href="/search/" class="btn btn-primary fw-bold w-25">
+ {{ search_title }}
+ </a>
+ </p>
+ <p>
+ <a href="/cdocs/" class="btn btn-primary fw-bold w-25">
+ Documentation
+ </a>
+ </p>
+ </p>
+ </main>
+
+ <footer class="mt-auto text-white-50">
+ <p>Copyright © 2016-2023 <a href="https://fd.io" class="text-white">The Fast Data Project</a>, a series of LF Projects, LLC.</p>
+ </footer>
+</div>
+{% endblock %}
diff --git a/csit.infra.dash/app/cdash/templates/dash_layout.jinja2 b/csit.infra.dash/app/cdash/templates/dash_layout.jinja2
new file mode 100644
index 0000000000..1275a99676
--- /dev/null
+++ b/csit.infra.dash/app/cdash/templates/dash_layout.jinja2
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <title>{%title%}</title>
+ {%metas%}
+ {%favicon%}
+ {%css%}
+</head>
+<body>
+ {%app_entry%}
+ <footer>
+ {%config%}
+ {%scripts%}
+ {%renderer%}
+ </footer>
+</body>
+</html> \ No newline at end of file
diff --git a/csit.infra.dash/app/cdash/templates/layout.jinja2 b/csit.infra.dash/app/cdash/templates/layout.jinja2
new file mode 100644
index 0000000000..e681f77af3
--- /dev/null
+++ b/csit.infra.dash/app/cdash/templates/layout.jinja2
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html lang="en" class="h-100">
+<head>
+ <meta charset="utf-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+ <title>{{ title }}</title>
+ <meta property="og:site_name" content="{{ title }}"/>
+ <meta property="og:type" content="website"/>
+ <meta property="og:title" content="{{ title }}"/>
+ <meta property="og:description" content="{{ description }}"/>
+ <meta property="og:url" content="https://csit.fd.io/"/>
+ <meta name="HandheldFriendly" content="True" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
+ <!-- Bootstrap core CSS -->
+{% assets "sass_all" %}
+ <link rel="stylesheet" href="{{ ASSET_URL }}" /></script>
+{% endassets %}
+ <!-- Favicons -->
+ <link rel="shortcut icon" href="{{ url_for('static', filename='dist/img/favicon.svg') }}" type="image/x-icon" />
+</head>
+<body class="d-flex h-100 text-center text-white bg-dark">
+ {% block content %}{% endblock %}
+</body>
+</html>
diff --git a/csit.infra.dash/app/cdash/trending/__init__.py b/csit.infra.dash/app/cdash/trending/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/trending/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/trending/graphs.py b/csit.infra.dash/app/cdash/trending/graphs.py
new file mode 100644
index 0000000000..1a507dfeea
--- /dev/null
+++ b/csit.infra.dash/app/cdash/trending/graphs.py
@@ -0,0 +1,679 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Implementation of graphs for trending data.
+"""
+
+import logging
+import plotly.graph_objects as go
+import pandas as pd
+
+from ..utils.constants import Constants as C
+from ..utils.utils import get_color, get_hdrh_latencies
+from ..utils.anomalies import classify_anomalies
+
+
+def select_trending_data(data: pd.DataFrame, itm: dict) -> pd.DataFrame:
+ """Select the data for graphs from the provided data frame.
+
+ :param data: Data frame with data for graphs.
+ :param itm: Item (in this case job name) which data will be selected from
+ the input data frame.
+ :type data: pandas.DataFrame
+ :type itm: dict
+ :returns: A data frame with selected data.
+ :rtype: pandas.DataFrame
+ """
+
+ phy = itm["phy"].split("-")
+ if len(phy) == 4:
+ topo, arch, nic, drv = phy
+ if drv == "dpdk":
+ drv = ""
+ else:
+ drv += "-"
+ drv = drv.replace("_", "-")
+ else:
+ return None
+
+ if itm["testtype"] in ("ndr", "pdr"):
+ test_type = "ndrpdr"
+ elif itm["testtype"] == "mrr":
+ test_type = "mrr"
+ elif itm["testtype"] == "soak":
+ test_type = "soak"
+ elif itm["area"] == "hoststack":
+ test_type = "hoststack"
+ df = data.loc[(
+ (data["test_type"] == test_type) &
+ (data["passed"] == True)
+ )]
+ df = df[df.job.str.endswith(f"{topo}-{arch}")]
+ core = str() if itm["dut"] == "trex" else f"{itm['core']}"
+ ttype = "ndrpdr" if itm["testtype"] in ("ndr", "pdr") else itm["testtype"]
+ df = df[df.test_id.str.contains(
+ f"^.*[.|-]{nic}.*{itm['framesize']}-{core}-{drv}{itm['test']}-{ttype}$",
+ regex=True
+ )].sort_values(by="start_time", ignore_index=True)
+
+ return df
+
+
+def graph_trending(
+ data: pd.DataFrame,
+ sel: dict,
+ layout: dict,
+ normalize: bool=False
+ ) -> tuple:
+ """Generate the trending graph(s) - MRR, NDR, PDR and for PDR also Latences
+ (result_latency_forward_pdr_50_avg).
+
+ :param data: Data frame with test results.
+ :param sel: Selected tests.
+ :param layout: Layout of plot.ly graph.
+ :param normalize: If True, the data is normalized to CPU frquency
+ Constants.NORM_FREQUENCY.
+ :type data: pandas.DataFrame
+ :type sel: dict
+ :type layout: dict
+ :type normalize: bool
+ :returns: Trending graph(s)
+ :rtype: tuple(plotly.graph_objects.Figure, plotly.graph_objects.Figure)
+ """
+
+ if not sel:
+ return None, None
+
+
+ def _generate_trending_traces(
+ ttype: str,
+ name: str,
+ df: pd.DataFrame,
+ color: str,
+ nf: float
+ ) -> list:
+ """Generate the trending traces for the trending graph.
+
+ :param ttype: Test type (MRR, NDR, PDR).
+ :param name: The test name to be displayed as the graph title.
+ :param df: Data frame with test data.
+ :param color: The color of the trace (samples and trend line).
+ :param nf: The factor used for normalization of the results to
+ CPU frequency set to Constants.NORM_FREQUENCY.
+ :type ttype: str
+ :type name: str
+ :type df: pandas.DataFrame
+ :type color: str
+ :type nf: float
+ :returns: Traces (samples, trending line, anomalies)
+ :rtype: list
+ """
+
+ df = df.dropna(subset=[C.VALUE[ttype], ])
+ if df.empty:
+ return list(), list()
+
+ hover = list()
+ customdata = list()
+ customdata_samples = list()
+ name_lst = name.split("-")
+ for _, row in df.iterrows():
+ h_tput, h_band, h_lat = str(), str(), str()
+ if ttype in ("mrr", "mrr-bandwidth"):
+ h_tput = (
+ f"tput avg [{row['result_receive_rate_rate_unit']}]: "
+ f"{row['result_receive_rate_rate_avg'] * nf:,.0f}<br>"
+ f"tput stdev [{row['result_receive_rate_rate_unit']}]: "
+ f"{row['result_receive_rate_rate_stdev'] * nf:,.0f}<br>"
+ )
+ if pd.notna(row["result_receive_rate_bandwidth_avg"]):
+ h_band = (
+ f"bandwidth avg "
+ f"[{row['result_receive_rate_bandwidth_unit']}]: "
+ f"{row['result_receive_rate_bandwidth_avg'] * nf:,.0f}"
+ "<br>"
+ f"bandwidth stdev "
+ f"[{row['result_receive_rate_bandwidth_unit']}]: "
+ f"{row['result_receive_rate_bandwidth_stdev']* nf:,.0f}"
+ "<br>"
+ )
+ elif ttype in ("ndr", "ndr-bandwidth"):
+ h_tput = (
+ f"tput [{row['result_ndr_lower_rate_unit']}]: "
+ f"{row['result_ndr_lower_rate_value'] * nf:,.0f}<br>"
+ )
+ if pd.notna(row["result_ndr_lower_bandwidth_value"]):
+ h_band = (
+ f"bandwidth [{row['result_ndr_lower_bandwidth_unit']}]:"
+ f" {row['result_ndr_lower_bandwidth_value'] * nf:,.0f}"
+ "<br>"
+ )
+ elif ttype in ("pdr", "pdr-bandwidth", "latency"):
+ h_tput = (
+ f"tput [{row['result_pdr_lower_rate_unit']}]: "
+ f"{row['result_pdr_lower_rate_value'] * nf:,.0f}<br>"
+ )
+ if pd.notna(row["result_pdr_lower_bandwidth_value"]):
+ h_band = (
+ f"bandwidth [{row['result_pdr_lower_bandwidth_unit']}]:"
+ f" {row['result_pdr_lower_bandwidth_value'] * nf:,.0f}"
+ "<br>"
+ )
+ if pd.notna(row["result_latency_forward_pdr_50_avg"]):
+ h_lat = (
+ f"latency "
+ f"[{row['result_latency_forward_pdr_50_unit']}]: "
+ f"{row['result_latency_forward_pdr_50_avg'] / nf:,.0f}"
+ "<br>"
+ )
+ elif ttype in ("hoststack-cps", "hoststack-rps",
+ "hoststack-cps-bandwidth",
+ "hoststack-rps-bandwidth", "hoststack-latency"):
+ h_tput = (
+ f"tput [{row['result_rate_unit']}]: "
+ f"{row['result_rate_value'] * nf:,.0f}<br>"
+ )
+ h_band = (
+ f"bandwidth [{row['result_bandwidth_unit']}]: "
+ f"{row['result_bandwidth_value'] * nf:,.0f}<br>"
+ )
+ h_lat = (
+ f"latency [{row['result_latency_unit']}]: "
+ f"{row['result_latency_value'] / nf:,.0f}<br>"
+ )
+ elif ttype in ("hoststack-bps", ):
+ h_band = (
+ f"bandwidth [{row['result_bandwidth_unit']}]: "
+ f"{row['result_bandwidth_value'] * nf:,.0f}<br>"
+ )
+ elif ttype in ("soak", "soak-bandwidth"):
+ h_tput = (
+ f"tput [{row['result_critical_rate_lower_rate_unit']}]: "
+ f"{row['result_critical_rate_lower_rate_value'] * nf:,.0f}"
+ "<br>"
+ )
+ if pd.notna(row["result_critical_rate_lower_bandwidth_value"]):
+ bv = row['result_critical_rate_lower_bandwidth_value']
+ h_band = (
+ "bandwidth "
+ f"[{row['result_critical_rate_lower_bandwidth_unit']}]:"
+ f" {bv * nf:,.0f}"
+ "<br>"
+ )
+ try:
+ hosts = f"<br>hosts: {', '.join(row['hosts'])}"
+ except (KeyError, TypeError):
+ hosts = str()
+ hover_itm = (
+ f"dut: {name_lst[0]}<br>"
+ f"infra: {'-'.join(name_lst[1:5])}<br>"
+ f"test: {'-'.join(name_lst[5:])}<br>"
+ f"date: {row['start_time'].strftime('%Y-%m-%d %H:%M:%S')}<br>"
+ f"{h_tput}{h_band}{h_lat}"
+ f"{row['dut_type']}-ref: {row['dut_version']}<br>"
+ f"csit-ref: {row['job']}/{row['build']}"
+ f"{hosts}"
+ )
+ hover.append(hover_itm)
+ if ttype == "latency":
+ customdata_samples.append(get_hdrh_latencies(row, name))
+ customdata.append({"name": name})
+ else:
+ customdata_samples.append(
+ {"name": name, "show_telemetry": True}
+ )
+ customdata.append({"name": name})
+
+ x_axis = df["start_time"].tolist()
+ if "latency" in ttype:
+ y_data = [(v / nf) for v in df[C.VALUE[ttype]].tolist()]
+ else:
+ y_data = [(v * nf) for v in df[C.VALUE[ttype]].tolist()]
+ units = df[C.UNIT[ttype]].unique().tolist()
+
+ try:
+ anomalies, trend_avg, trend_stdev = classify_anomalies(
+ {k: v for k, v in zip(x_axis, y_data)}
+ )
+ except ValueError as err:
+ logging.error(err)
+ return list(), list()
+
+ hover_trend = list()
+ for avg, stdev, (_, row) in zip(trend_avg, trend_stdev, df.iterrows()):
+ try:
+ hosts = f"<br>hosts: {', '.join(row['hosts'])}"
+ except (KeyError, TypeError):
+ hosts = str()
+ hover_itm = (
+ f"dut: {name_lst[0]}<br>"
+ f"infra: {'-'.join(name_lst[1:5])}<br>"
+ f"test: {'-'.join(name_lst[5:])}<br>"
+ f"date: {row['start_time'].strftime('%Y-%m-%d %H:%M:%S')}<br>"
+ f"trend [{row[C.UNIT[ttype]]}]: {avg:,.0f}<br>"
+ f"stdev [{row[C.UNIT[ttype]]}]: {stdev:,.0f}<br>"
+ f"{row['dut_type']}-ref: {row['dut_version']}<br>"
+ f"csit-ref: {row['job']}/{row['build']}"
+ f"{hosts}"
+ )
+ if ttype == "latency":
+ hover_itm = hover_itm.replace("[pps]", "[us]")
+ hover_trend.append(hover_itm)
+
+ traces = [
+ go.Scatter( # Samples
+ x=x_axis,
+ y=y_data,
+ name=name,
+ mode="markers",
+ marker={
+ "size": 5,
+ "color": color,
+ "symbol": "circle",
+ },
+ text=hover,
+ hoverinfo="text",
+ showlegend=True,
+ legendgroup=name,
+ customdata=customdata_samples
+ ),
+ go.Scatter( # Trend line
+ x=x_axis,
+ y=trend_avg,
+ name=name,
+ mode="lines",
+ line={
+ "shape": "linear",
+ "width": 1,
+ "color": color,
+ },
+ text=hover_trend,
+ hoverinfo="text",
+ showlegend=False,
+ legendgroup=name,
+ customdata=customdata
+ )
+ ]
+
+ if anomalies:
+ anomaly_x = list()
+ anomaly_y = list()
+ anomaly_color = list()
+ hover = list()
+ for idx, anomaly in enumerate(anomalies):
+ if anomaly in ("regression", "progression"):
+ anomaly_x.append(x_axis[idx])
+ anomaly_y.append(trend_avg[idx])
+ anomaly_color.append(C.ANOMALY_COLOR[anomaly])
+ hover_itm = (
+ f"dut: {name_lst[0]}<br>"
+ f"infra: {'-'.join(name_lst[1:5])}<br>"
+ f"test: {'-'.join(name_lst[5:])}<br>"
+ f"date: {x_axis[idx].strftime('%Y-%m-%d %H:%M:%S')}<br>"
+ f"trend [pps]: {trend_avg[idx]:,.0f}<br>"
+ f"classification: {anomaly}"
+ )
+ if ttype == "latency":
+ hover_itm = hover_itm.replace("[pps]", "[us]")
+ hover.append(hover_itm)
+ anomaly_color.extend([0.0, 0.5, 1.0])
+ traces.append(
+ go.Scatter(
+ x=anomaly_x,
+ y=anomaly_y,
+ mode="markers",
+ text=hover,
+ hoverinfo="text",
+ showlegend=False,
+ legendgroup=name,
+ name=name,
+ customdata=customdata,
+ marker={
+ "size": 15,
+ "symbol": "circle-open",
+ "color": anomaly_color,
+ "colorscale": C.COLORSCALE_LAT \
+ if ttype == "latency" else C.COLORSCALE_TPUT,
+ "showscale": True,
+ "line": {
+ "width": 2
+ },
+ "colorbar": {
+ "y": 0.5,
+ "len": 0.8,
+ "title": "Circles Marking Data Classification",
+ "titleside": "right",
+ "tickmode": "array",
+ "tickvals": [0.167, 0.500, 0.833],
+ "ticktext": C.TICK_TEXT_LAT \
+ if ttype == "latency" else C.TICK_TEXT_TPUT,
+ "ticks": "",
+ "ticklen": 0,
+ "tickangle": -90,
+ "thickness": 10
+ }
+ }
+ )
+ )
+
+ return traces, units
+
+
+ fig_tput = None
+ fig_lat = None
+ fig_band = None
+ y_units = set()
+ for idx, itm in enumerate(sel):
+ df = select_trending_data(data, itm)
+ if df is None or df.empty:
+ continue
+
+ if normalize:
+ phy = itm["phy"].split("-")
+ topo_arch = f"{phy[0]}-{phy[1]}" if len(phy) == 4 else str()
+ norm_factor = (C.NORM_FREQUENCY / C.FREQUENCY.get(topo_arch, 1.0)) \
+ if topo_arch else 1.0
+ else:
+ norm_factor = 1.0
+
+ if itm["area"] == "hoststack":
+ ttype = f"hoststack-{itm['testtype']}"
+ else:
+ ttype = itm["testtype"]
+
+ traces, units = _generate_trending_traces(
+ ttype,
+ itm["id"],
+ df,
+ get_color(idx),
+ norm_factor
+ )
+ if traces:
+ if not fig_tput:
+ fig_tput = go.Figure()
+ fig_tput.add_traces(traces)
+
+ if ttype in C.TESTS_WITH_BANDWIDTH:
+ traces, _ = _generate_trending_traces(
+ f"{ttype}-bandwidth",
+ itm["id"],
+ df,
+ get_color(idx),
+ norm_factor
+ )
+ if traces:
+ if not fig_band:
+ fig_band = go.Figure()
+ fig_band.add_traces(traces)
+
+ if ttype in C.TESTS_WITH_LATENCY:
+ traces, _ = _generate_trending_traces(
+ "latency" if ttype == "pdr" else "hoststack-latency",
+ itm["id"],
+ df,
+ get_color(idx),
+ norm_factor
+ )
+ if traces:
+ if not fig_lat:
+ fig_lat = go.Figure()
+ fig_lat.add_traces(traces)
+
+ y_units.update(units)
+
+ if fig_tput:
+ fig_layout = layout.get("plot-trending-tput", dict())
+ fig_layout["yaxis"]["title"] = \
+ f"Throughput [{'|'.join(sorted(y_units))}]"
+ fig_tput.update_layout(fig_layout)
+ if fig_band:
+ fig_band.update_layout(layout.get("plot-trending-bandwidth", dict()))
+ if fig_lat:
+ fig_lat.update_layout(layout.get("plot-trending-lat", dict()))
+
+ return fig_tput, fig_band, fig_lat
+
+
+def graph_tm_trending(
+ data: pd.DataFrame,
+ layout: dict,
+ all_in_one: bool=False
+ ) -> list:
+ """Generates one trending graph per test, each graph includes all selected
+ metrics.
+
+ :param data: Data frame with telemetry data.
+ :param layout: Layout of plot.ly graph.
+ :param all_in_one: If True, all telemetry traces are placed in one graph,
+ otherwise they are split to separate graphs grouped by test ID.
+ :type data: pandas.DataFrame
+ :type layout: dict
+ :type all_in_one: bool
+ :returns: List of generated graphs together with test names.
+ list(tuple(plotly.graph_objects.Figure(), str()), tuple(...), ...)
+ :rtype: list
+ """
+
+ if data.empty:
+ return list()
+
+ def _generate_traces(
+ data: pd.DataFrame,
+ test: str,
+ all_in_one: bool,
+ color_index: int
+ ) -> list:
+ """Generates a trending graph for given test with all metrics.
+
+ :param data: Data frame with telemetry data for the given test.
+ :param test: The name of the test.
+ :param all_in_one: If True, all telemetry traces are placed in one
+ graph, otherwise they are split to separate graphs grouped by
+ test ID.
+ :param color_index: The index of the test used if all_in_one is True.
+ :type data: pandas.DataFrame
+ :type test: str
+ :type all_in_one: bool
+ :type color_index: int
+ :returns: List of traces.
+ :rtype: list
+ """
+ traces = list()
+ metrics = data.tm_metric.unique().tolist()
+ for idx, metric in enumerate(metrics):
+ if "-pdr" in test and "='pdr'" not in metric:
+ continue
+ if "-ndr" in test and "='ndr'" not in metric:
+ continue
+
+ df = data.loc[(data["tm_metric"] == metric)]
+ x_axis = df["start_time"].tolist()
+ y_data = [float(itm) for itm in df["tm_value"].tolist()]
+ hover = list()
+ for i, (_, row) in enumerate(df.iterrows()):
+ if row["test_type"] == "mrr":
+ rate = (
+ f"mrr avg [{row[C.UNIT['mrr']]}]: "
+ f"{row[C.VALUE['mrr']]:,.0f}<br>"
+ f"mrr stdev [{row[C.UNIT['mrr']]}]: "
+ f"{row['result_receive_rate_rate_stdev']:,.0f}<br>"
+ )
+ elif row["test_type"] == "ndrpdr":
+ if "-pdr" in test:
+ rate = (
+ f"pdr [{row[C.UNIT['pdr']]}]: "
+ f"{row[C.VALUE['pdr']]:,.0f}<br>"
+ )
+ elif "-ndr" in test:
+ rate = (
+ f"ndr [{row[C.UNIT['ndr']]}]: "
+ f"{row[C.VALUE['ndr']]:,.0f}<br>"
+ )
+ else:
+ rate = str()
+ else:
+ rate = str()
+ hover.append(
+ f"date: "
+ f"{row['start_time'].strftime('%Y-%m-%d %H:%M:%S')}<br>"
+ f"value: {y_data[i]:,.2f}<br>"
+ f"{rate}"
+ f"{row['dut_type']}-ref: {row['dut_version']}<br>"
+ f"csit-ref: {row['job']}/{row['build']}<br>"
+ )
+ if any(y_data):
+ anomalies, trend_avg, trend_stdev = classify_anomalies(
+ {k: v for k, v in zip(x_axis, y_data)}
+ )
+ hover_trend = list()
+ for avg, stdev, (_, row) in \
+ zip(trend_avg, trend_stdev, df.iterrows()):
+ hover_trend.append(
+ f"date: "
+ f"{row['start_time'].strftime('%Y-%m-%d %H:%M:%S')}<br>"
+ f"trend: {avg:,.2f}<br>"
+ f"stdev: {stdev:,.2f}<br>"
+ f"{row['dut_type']}-ref: {row['dut_version']}<br>"
+ f"csit-ref: {row['job']}/{row['build']}"
+ )
+ else:
+ anomalies = None
+ if all_in_one:
+ color = get_color(color_index * len(metrics) + idx)
+ metric_name = f"{test}<br>{metric}"
+ else:
+ color = get_color(idx)
+ metric_name = metric
+
+ traces.append(
+ go.Scatter( # Samples
+ x=x_axis,
+ y=y_data,
+ name=metric_name,
+ mode="markers",
+ marker={
+ "size": 5,
+ "color": color,
+ "symbol": "circle",
+ },
+ text=hover,
+ hoverinfo="text+name",
+ showlegend=True,
+ legendgroup=metric_name
+ )
+ )
+ if anomalies:
+ traces.append(
+ go.Scatter( # Trend line
+ x=x_axis,
+ y=trend_avg,
+ name=metric_name,
+ mode="lines",
+ line={
+ "shape": "linear",
+ "width": 1,
+ "color": color,
+ },
+ text=hover_trend,
+ hoverinfo="text+name",
+ showlegend=False,
+ legendgroup=metric_name
+ )
+ )
+
+ anomaly_x = list()
+ anomaly_y = list()
+ anomaly_color = list()
+ hover = list()
+ for idx, anomaly in enumerate(anomalies):
+ if anomaly in ("regression", "progression"):
+ anomaly_x.append(x_axis[idx])
+ anomaly_y.append(trend_avg[idx])
+ anomaly_color.append(C.ANOMALY_COLOR[anomaly])
+ hover_itm = (
+ f"date: {x_axis[idx].strftime('%Y-%m-%d %H:%M:%S')}"
+ f"<br>trend: {trend_avg[idx]:,.2f}"
+ f"<br>classification: {anomaly}"
+ )
+ hover.append(hover_itm)
+ anomaly_color.extend([0.0, 0.5, 1.0])
+ traces.append(
+ go.Scatter(
+ x=anomaly_x,
+ y=anomaly_y,
+ mode="markers",
+ text=hover,
+ hoverinfo="text+name",
+ showlegend=False,
+ legendgroup=metric_name,
+ name=metric_name,
+ marker={
+ "size": 15,
+ "symbol": "circle-open",
+ "color": anomaly_color,
+ "colorscale": C.COLORSCALE_TPUT,
+ "showscale": True,
+ "line": {
+ "width": 2
+ },
+ "colorbar": {
+ "y": 0.5,
+ "len": 0.8,
+ "title": "Circles Marking Data Classification",
+ "titleside": "right",
+ "tickmode": "array",
+ "tickvals": [0.167, 0.500, 0.833],
+ "ticktext": C.TICK_TEXT_TPUT,
+ "ticks": "",
+ "ticklen": 0,
+ "tickangle": -90,
+ "thickness": 10
+ }
+ }
+ )
+ )
+
+ unique_metrics = set()
+ for itm in metrics:
+ unique_metrics.add(itm.split("{", 1)[0])
+ return traces, unique_metrics
+
+ tm_trending_graphs = list()
+ graph_layout = layout.get("plot-trending-telemetry", dict())
+
+ if all_in_one:
+ all_traces = list()
+
+ all_metrics = set()
+ all_tests = list()
+ for idx, test in enumerate(data.test_name.unique()):
+ df = data.loc[(data["test_name"] == test)]
+ traces, metrics = _generate_traces(df, test, all_in_one, idx)
+ if traces:
+ all_metrics.update(metrics)
+ if all_in_one:
+ all_traces.extend(traces)
+ all_tests.append(test)
+ else:
+ graph = go.Figure()
+ graph.add_traces(traces)
+ graph.update_layout(graph_layout)
+ tm_trending_graphs.append((graph, [test, ], ))
+
+ if all_in_one:
+ graph = go.Figure()
+ graph.add_traces(all_traces)
+ graph.update_layout(graph_layout)
+ tm_trending_graphs.append((graph, all_tests, ))
+
+ return tm_trending_graphs, list(all_metrics)
diff --git a/csit.infra.dash/app/cdash/trending/layout.py b/csit.infra.dash/app/cdash/trending/layout.py
new file mode 100644
index 0000000000..da90ae26f9
--- /dev/null
+++ b/csit.infra.dash/app/cdash/trending/layout.py
@@ -0,0 +1,1721 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+"""Plotly Dash HTML layout override.
+"""
+
+import logging
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from flask import Flask
+from dash import dcc
+from dash import html
+from dash import callback_context, no_update, ALL
+from dash import Input, Output, State
+from dash.exceptions import PreventUpdate
+from yaml import load, FullLoader, YAMLError
+from ast import literal_eval
+from copy import deepcopy
+
+from ..utils.constants import Constants as C
+from ..utils.control_panel import ControlPanel
+from ..utils.trigger import Trigger
+from ..utils.telemetry_data import TelemetryData
+from ..utils.utils import show_tooltip, label, sync_checklists, gen_new_url, \
+ generate_options, get_list_group_items, navbar_trending, \
+ show_trending_graph_data
+from ..utils.url_processing import url_decode
+from .graphs import graph_trending, select_trending_data, graph_tm_trending
+
+
+# Control panel partameters and their default values.
+CP_PARAMS = {
+ "dd-dut-val": str(),
+ "dd-phy-opt": list(),
+ "dd-phy-dis": True,
+ "dd-phy-val": str(),
+ "dd-area-opt": list(),
+ "dd-area-dis": True,
+ "dd-area-val": str(),
+ "dd-test-opt": list(),
+ "dd-test-dis": True,
+ "dd-test-val": str(),
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True,
+ "cl-normalize-val": list()
+}
+
+
+class Layout:
+ """The layout of the dash app and the callbacks.
+ """
+
+ def __init__(self,
+ app: Flask,
+ data_trending: pd.DataFrame,
+ html_layout_file: str,
+ graph_layout_file: str,
+ tooltip_file: str
+ ) -> None:
+ """Initialization:
+ - save the input parameters,
+ - read and pre-process the data,
+ - prepare data for the control panel,
+ - read HTML layout file,
+ - read tooltips from the tooltip file.
+
+ :param app: Flask application running the dash application.
+ :param data_trending: Pandas dataframe with trending data.
+ :param html_layout_file: Path and name of the file specifying the HTML
+ layout of the dash application.
+ :param graph_layout_file: Path and name of the file with layout of
+ plot.ly graphs.
+ :param tooltip_file: Path and name of the yaml file specifying the
+ tooltips.
+ :type app: Flask
+ :type data_trending: pandas.DataFrame
+ :type html_layout_file: str
+ :type graph_layout_file: str
+ :type tooltip_file: str
+ """
+
+ # Inputs
+ self._app = app
+ self._data = data_trending
+ self._html_layout_file = html_layout_file
+ self._graph_layout_file = graph_layout_file
+ self._tooltip_file = tooltip_file
+
+ # Get structure of tests:
+ tbs = dict()
+ cols = ["job", "test_id", "test_type", "tg_type"]
+ for _, row in self._data[cols].drop_duplicates().iterrows():
+ lst_job = row["job"].split("-")
+ dut = lst_job[1]
+ tbed = "-".join(lst_job[-2:])
+ lst_test = row["test_id"].split(".")
+ if dut == "dpdk":
+ area = "dpdk"
+ else:
+ area = ".".join(lst_test[3:-2])
+ suite = lst_test[-2].replace("2n1l-", "").replace("1n1l-", "").\
+ replace("2n-", "")
+ test = lst_test[-1]
+ nic = suite.split("-")[0]
+ for drv in C.DRIVERS:
+ if drv in test:
+ if drv == "af-xdp":
+ driver = "af_xdp"
+ else:
+ driver = drv
+ test = test.replace(f"{drv}-", "")
+ break
+ else:
+ driver = "dpdk"
+ infra = "-".join((tbed, nic, driver))
+ lst_test = test.split("-")
+ framesize = lst_test[0]
+ core = lst_test[1] if lst_test[1] else "8C"
+ test = "-".join(lst_test[2: -1])
+
+ if tbs.get(dut, None) is None:
+ tbs[dut] = dict()
+ if tbs[dut].get(area, None) is None:
+ tbs[dut][area] = dict()
+ if tbs[dut][area].get(test, None) is None:
+ tbs[dut][area][test] = dict()
+ if tbs[dut][area][test].get(infra, None) is None:
+ tbs[dut][area][test][infra] = {
+ "core": list(),
+ "frame-size": list(),
+ "test-type": list()
+ }
+ tst_params = tbs[dut][area][test][infra]
+ if core.upper() not in tst_params["core"]:
+ tst_params["core"].append(core.upper())
+ if framesize.upper() not in tst_params["frame-size"]:
+ tst_params["frame-size"].append(framesize.upper())
+ if row["test_type"] == "ndrpdr":
+ if "NDR" not in tst_params["test-type"]:
+ tst_params["test-type"].extend(("NDR", "PDR"))
+ elif row["test_type"] == "hoststack":
+ if row["tg_type"] in ("iperf", "vpp"):
+ if "BPS" not in tst_params["test-type"]:
+ tst_params["test-type"].append("BPS")
+ elif row["tg_type"] == "ab":
+ if "CPS" not in tst_params["test-type"]:
+ tst_params["test-type"].extend(("CPS", "RPS"))
+ else: # MRR, SOAK
+ if row["test_type"].upper() not in tst_params["test-type"]:
+ tst_params["test-type"].append(row["test_type"].upper())
+ self._spec_tbs = tbs
+
+ # Read from files:
+ self._html_layout = str()
+ self._graph_layout = None
+ self._tooltips = dict()
+
+ try:
+ with open(self._html_layout_file, "r") as file_read:
+ self._html_layout = file_read.read()
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._html_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._graph_layout_file, "r") as file_read:
+ self._graph_layout = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._graph_layout_file}\n"
+ f"{err}"
+ )
+ except YAMLError as err:
+ raise RuntimeError(
+ f"An error occurred while parsing the specification file "
+ f"{self._graph_layout_file}\n{err}"
+ )
+
+ try:
+ with open(self._tooltip_file, "r") as file_read:
+ self._tooltips = load(file_read, Loader=FullLoader)
+ except IOError as err:
+ logging.warning(
+ f"Not possible to open the file {self._tooltip_file}\n{err}"
+ )
+ except YAMLError as err:
+ logging.warning(
+ f"An error occurred while parsing the specification file "
+ f"{self._tooltip_file}\n{err}"
+ )
+
+ # Callbacks:
+ if self._app is not None and hasattr(self, "callbacks"):
+ self.callbacks(self._app)
+
+ @property
+ def html_layout(self):
+ return self._html_layout
+
+ def add_content(self):
+ """Top level method which generated the web page.
+
+ It generates:
+ - Store for user input data,
+ - Navigation bar,
+ - Main area with control panel and ploting area.
+
+ If no HTML layout is provided, an error message is displayed instead.
+
+ :returns: The HTML div with the whole page.
+ :rtype: html.Div
+ """
+
+ if self.html_layout and self._spec_tbs:
+ return html.Div(
+ id="div-main",
+ className="small",
+ children=[
+ dcc.Store(id="store"),
+ dcc.Location(id="url", refresh=False),
+ dbc.Row(
+ id="row-navbar",
+ class_name="g-0",
+ children=[navbar_trending((True, False, False, False))]
+ ),
+ dbc.Row(
+ id="row-main",
+ class_name="g-0",
+ children=[
+ self._add_ctrl_col(),
+ self._add_plotting_col()
+ ]
+ ),
+ dbc.Spinner(
+ dbc.Offcanvas(
+ class_name="w-50",
+ id="offcanvas-metadata",
+ title="Detailed Information",
+ placement="end",
+ is_open=False,
+ children=[
+ dbc.Row(id="metadata-tput-lat"),
+ dbc.Row(id="metadata-hdrh-graph")
+ ]
+ ),
+ delay_show=C.SPINNER_DELAY
+ ),
+ dbc.Offcanvas(
+ class_name="w-75",
+ id="offcanvas-documentation",
+ title="Documentation",
+ placement="end",
+ is_open=False,
+ children=html.Iframe(
+ src=C.URL_DOC_TRENDING,
+ width="100%",
+ height="100%"
+ )
+ )
+ ]
+ )
+ else:
+ return html.Div(
+ dbc.Alert("An Error Occured", color="danger"),
+ id="div-main-error"
+ )
+
+ def _add_ctrl_col(self) -> dbc.Col:
+ """Add column with controls. It is placed on the left side.
+
+ :returns: Column with the control panel.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(html.Div(self._add_ctrl_panel(), className="sticky-top"))
+
+ def _add_ctrl_panel(self) -> list:
+ """Add control panel.
+
+ :returns: Control panel.
+ :rtype: list
+ """
+ return [
+ dbc.Row(
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ show_tooltip(self._tooltips, "help-dut", "DUT")
+ ),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dut"},
+ placeholder="Select a Device under Test...",
+ options=sorted(
+ [
+ {"label": k, "value": k} \
+ for k in self._spec_tbs.keys()
+ ],
+ key=lambda d: d["label"]
+ )
+ )
+ ],
+ size="sm"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ show_tooltip(self._tooltips, "help-area", "Area")
+ ),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "area"},
+ placeholder="Select an Area..."
+ )
+ ],
+ size="sm"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ show_tooltip(self._tooltips, "help-test", "Test")
+ ),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "test"},
+ placeholder="Select a Test..."
+ )
+ ],
+ size="sm"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(
+ show_tooltip(self._tooltips, "help-infra", "Infra")
+ ),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "phy"},
+ placeholder="Select a Physical Test Bed Topology..."
+ )
+ ],
+ size="sm"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-framesize",
+ "Frame Size"
+ )),
+ dbc.Col(
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "frmsize-all"},
+ options=C.CL_ALL_DISABLED,
+ inline=True,
+ class_name="ms-2"
+ ),
+ width=2
+ ),
+ dbc.Col(
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "frmsize"},
+ inline=True
+ )
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-cores",
+ "Number of Cores"
+ )),
+ dbc.Col(
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "core-all"},
+ options=C.CL_ALL_DISABLED,
+ inline=True,
+ class_name="ms-2"
+ ),
+ width=2
+ ),
+ dbc.Col(
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "core"},
+ inline=True
+ )
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-ttype",
+ "Test Type"
+ )),
+ dbc.Col(
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "tsttype-all"},
+ options=C.CL_ALL_DISABLED,
+ inline=True,
+ class_name="ms-2"
+ ),
+ width=2
+ ),
+ dbc.Col(
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "tsttype"},
+ inline=True
+ )
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText(show_tooltip(
+ self._tooltips,
+ "help-normalize",
+ "Normalization"
+ )),
+ dbc.Col(dbc.Checklist(
+ id="normalize",
+ options=[{
+ "value": "normalize",
+ "label": "Normalize to CPU frequency 2GHz"
+ }],
+ value=[],
+ inline=True,
+ class_name="ms-2"
+ ))
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.Button(
+ id={"type": "ctrl-btn", "index": "add-test"},
+ children="Add Selected",
+ color="info"
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.ListGroup(
+ class_name="overflow-auto p-0",
+ id="lg-selected",
+ children=[],
+ style={"max-height": "20em"},
+ flush=True
+ ),
+ id="row-card-sel-tests",
+ class_name="g-0 p-1",
+ style=C.STYLE_DISABLED,
+ ),
+ dbc.Row(
+ dbc.ButtonGroup([
+ dbc.Button(
+ "Remove Selected",
+ id={"type": "ctrl-btn", "index": "rm-test"},
+ class_name="w-100",
+ color="info",
+ disabled=False
+ ),
+ dbc.Button(
+ "Remove All",
+ id={"type": "ctrl-btn", "index": "rm-test-all"},
+ class_name="w-100",
+ color="info",
+ disabled=False
+ )
+ ]),
+ id="row-btns-sel-tests",
+ class_name="g-0 p-1",
+ style=C.STYLE_DISABLED,
+ ),
+ dbc.Stack(
+ [
+ dbc.Button(
+ "Add Telemetry Panel",
+ id={"type": "telemetry-btn", "index": "open"},
+ color="info"
+ ),
+ dbc.Button("Show URL", id="plot-btn-url", color="info"),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(dbc.ModalTitle("URL")),
+ dbc.ModalBody(id="mod-url")
+ ],
+ id="plot-mod-url",
+ size="xl",
+ is_open=False,
+ scrollable=True
+ )
+ ],
+ id="row-btns-add-tm",
+ class_name="g-0 p-1",
+ style=C.STYLE_DISABLED,
+ gap=2
+ )
+ ]
+
+ def _add_plotting_col(self) -> dbc.Col:
+ """Add column with plots. It is placed on the right side.
+
+ :returns: Column with plots.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(
+ id="col-plotting-area",
+ children=[
+ dbc.Row(
+ id="plotting-area-trending",
+ class_name="g-0 p-0",
+ children=C.PLACEHOLDER
+ ),
+ dbc.Row(
+ id="plotting-area-telemetry",
+ class_name="g-0 p-0",
+ children=C.PLACEHOLDER
+ )
+ ],
+ width=9,
+ style=C.STYLE_DISABLED,
+ )
+
+ @staticmethod
+ def _plotting_area_trending(graphs: list) -> dbc.Col:
+ """Generate the plotting area with all its content.
+
+ :param graphs: A list of graphs to be displayed in the trending page.
+ :type graphs: list
+ :returns: A collumn with trending graphs (tput and latency) in tabs.
+ :rtype: dbc.Col
+ """
+ if not graphs:
+ return C.PLACEHOLDER
+
+ if not graphs[0]:
+ return C.PLACEHOLDER
+
+ tab_items = [
+ dbc.Tab(
+ children=dcc.Graph(
+ id={"type": "graph", "index": "tput"},
+ figure=graphs[0]
+ ),
+ label="Throughput",
+ tab_id="tab-tput"
+ )
+ ]
+
+ if graphs[1]:
+ tab_items.append(
+ dbc.Tab(
+ children=dcc.Graph(
+ id={"type": "graph", "index": "bandwidth"},
+ figure=graphs[1]
+ ),
+ label="Bandwidth",
+ tab_id="tab-bandwidth"
+ )
+ )
+
+ if graphs[2]:
+ tab_items.append(
+ dbc.Tab(
+ children=dcc.Graph(
+ id={"type": "graph", "index": "lat"},
+ figure=graphs[2]
+ ),
+ label="Latency",
+ tab_id="tab-lat"
+ )
+ )
+
+ trending = [
+ dbc.Row(
+ dbc.Tabs(
+ children=tab_items,
+ id="tabs",
+ active_tab="tab-tput",
+ ),
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ html.Div(
+ [
+ dbc.Button(
+ "Download Data",
+ id="plot-btn-download",
+ class_name="me-1",
+ color="info",
+ style={"padding": "0rem 1rem"}
+ ),
+ dcc.Download(id="download-trending-data")
+ ],
+ className="d-grid gap-0 d-md-flex justify-content-md-end"
+ ),
+ class_name="g-0 p-0"
+ )
+ ]
+
+ return dbc.Col(
+ children=[
+ dbc.Accordion(
+ dbc.AccordionItem(trending, title="Trending"),
+ class_name="g-0 p-1",
+ start_collapsed=False,
+ always_open=True,
+ active_item=["item-0", ]
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(
+ dbc.ModalTitle("Select a Metric"),
+ close_button=False
+ ),
+ dbc.Spinner(
+ dbc.ModalBody(Layout._get_telemetry_step_1()),
+ delay_show=2 * C.SPINNER_DELAY
+ ),
+ dbc.ModalFooter([
+ dbc.Button(
+ "Select",
+ id={"type": "telemetry-btn", "index": "select"},
+ color="success",
+ disabled=True
+ ),
+ dbc.Button(
+ "Cancel",
+ id={"type": "telemetry-btn", "index": "cancel"},
+ color="info",
+ disabled=False
+ ),
+ dbc.Button(
+ "Remove All",
+ id={"type": "telemetry-btn", "index": "rm-all"},
+ color="danger",
+ disabled=False
+ )
+ ])
+ ],
+ id={"type": "plot-mod-telemetry", "index": 0},
+ size="lg",
+ is_open=False,
+ scrollable=False,
+ backdrop="static",
+ keyboard=False
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(
+ dbc.ModalTitle("Select Labels"),
+ close_button=False
+ ),
+ dbc.Spinner(
+ dbc.ModalBody(Layout._get_telemetry_step_2()),
+ delay_show=2 * C.SPINNER_DELAY
+ ),
+ dbc.ModalFooter([
+ dbc.Button(
+ "Back",
+ id={"type": "telemetry-btn", "index": "back"},
+ color="info",
+ disabled=False
+ ),
+ dbc.Button(
+ "Add Telemetry Panel",
+ id={"type": "telemetry-btn", "index": "add"},
+ color="success",
+ disabled=True
+ ),
+ dbc.Button(
+ "Cancel",
+ id={"type": "telemetry-btn", "index": "cancel"},
+ color="info",
+ disabled=False
+ )
+ ])
+ ],
+ id={"type": "plot-mod-telemetry", "index": 1},
+ size="xl",
+ is_open=False,
+ scrollable=False,
+ backdrop="static",
+ keyboard=False
+ )
+ ]
+ )
+
+ @staticmethod
+ def _plotting_area_telemetry(graphs: list) -> dbc.Col:
+ """Generate the plotting area with telemetry.
+
+ :param graphs: A list of graphs to be displayed in the telemetry page.
+ :type graphs: list
+ :returns: A collumn with telemetry trending graphs.
+ :rtype: dbc.Col
+ """
+ if not graphs:
+ return C.PLACEHOLDER
+
+ def _plural(iterative):
+ return "s" if len(iterative) > 1 else str()
+
+ panels = list()
+ for idx, graph_set in enumerate(graphs):
+ acc_items = list()
+ for graph in graph_set[0]:
+ graph_name = ", ".join(graph[1])
+ acc_items.append(
+ dbc.AccordionItem(
+ dcc.Graph(
+ id={"type": "graph-telemetry", "index": graph_name},
+ figure=graph[0]
+ ),
+ title=(f"Test{_plural(graph[1])}: {graph_name}"),
+ class_name="g-0 p-0"
+ )
+ )
+ panels.append(
+ dbc.AccordionItem(
+ [
+ dbc.Row(
+ dbc.Accordion(
+ children=acc_items,
+ class_name="g-0 p-0",
+ start_collapsed=True,
+ always_open=True,
+ flush=True
+ ),
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ html.Div(
+ [
+ dbc.Button(
+ "Remove",
+ id={
+ "type": "tm-btn-remove",
+ "index": idx
+ },
+ class_name="me-1",
+ color="danger",
+ style={"padding": "0rem 1rem"}
+ ),
+ dbc.Button(
+ "Download Data",
+ id={
+ "type": "tm-btn-download",
+ "index": idx
+ },
+ class_name="me-1",
+ color="info",
+ style={"padding": "0rem 1rem"}
+ )
+ ],
+ className=\
+ "d-grid gap-0 d-md-flex justify-content-md-end"
+ ),
+ class_name="g-0 p-0"
+ )
+ ],
+ class_name="g-0 p-0",
+ title=(
+ f"Metric{_plural(graph_set[1])}: ",
+ ", ".join(graph_set[1])
+ )
+ )
+ )
+
+ return dbc.Col(
+ dbc.Accordion(
+ panels,
+ class_name="g-0 p-1",
+ start_collapsed=True,
+ always_open=True
+ )
+ )
+
+ @staticmethod
+ def _get_telemetry_step_1() -> list:
+ """Return the content of the modal window used in the step 1 of metrics
+ selection.
+
+ :returns: A list of dbc rows with 'input' and 'search output'.
+ :rtype: list
+ """
+ return [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.Input(
+ id={"type": "telemetry-search-in", "index": 0},
+ placeholder="Start typing a metric name...",
+ type="text"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.ListGroup(
+ class_name="overflow-auto p-0",
+ id={"type": "telemetry-search-out", "index": 0},
+ children=[],
+ style={"max-height": "14em"},
+ flush=True
+ )
+ ]
+ )
+ ]
+
+ @staticmethod
+ def _get_telemetry_step_2() -> list:
+ """Return the content of the modal window used in the step 2 of metrics
+ selection.
+
+ :returns: A list of dbc rows with 'container with dynamic dropdowns' and
+ 'search output'.
+ :rtype: list
+ """
+ return [
+ dbc.Row(
+ "Add content here.",
+ id={"type": "tm-container", "index": 0},
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ [
+ dbc.Col(
+ dbc.Checkbox(
+ id={"type": "cb-all-in-one", "index": 0},
+ label="All Metrics in one Graph"
+ ),
+ width=6
+ ),
+ dbc.Col(
+ dbc.Checkbox(
+ id={"type": "cb-ignore-host", "index": 0},
+ label="Ignore Host"
+ ),
+ width=6
+ )
+ ],
+ class_name="g-0 p-2"
+ ),
+ dbc.Row(
+ dbc.Textarea(
+ id={"type": "tm-list-metrics", "index": 0},
+ rows=20,
+ size="sm",
+ wrap="off",
+ readonly=True
+ ),
+ class_name="g-0 p-1"
+ )
+ ]
+
+ def callbacks(self, app):
+ """Callbacks for the whole application.
+
+ :param app: The application.
+ :type app: Flask
+ """
+
+ @app.callback(
+ Output("store", "data"),
+ Output("plotting-area-trending", "children"),
+ Output("plotting-area-telemetry", "children"),
+ Output("col-plotting-area", "style"),
+ Output("row-card-sel-tests", "style"),
+ Output("row-btns-sel-tests", "style"),
+ Output("row-btns-add-tm", "style"),
+ Output("lg-selected", "children"),
+ Output({"type": "telemetry-search-out", "index": ALL}, "children"),
+ Output({"type": "plot-mod-telemetry", "index": ALL}, "is_open"),
+ Output({"type": "telemetry-btn", "index": ALL}, "disabled"),
+ Output({"type": "tm-container", "index": ALL}, "children"),
+ Output({"type": "tm-list-metrics", "index": ALL}, "value"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "value"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "options"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "phy"}, "value"),
+ Output({"type": "ctrl-dd", "index": "area"}, "options"),
+ Output({"type": "ctrl-dd", "index": "area"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "area"}, "value"),
+ Output({"type": "ctrl-dd", "index": "test"}, "options"),
+ Output({"type": "ctrl-dd", "index": "test"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "test"}, "value"),
+ Output({"type": "ctrl-cl", "index": "core"}, "options"),
+ Output({"type": "ctrl-cl", "index": "core"}, "value"),
+ Output({"type": "ctrl-cl", "index": "core-all"}, "value"),
+ Output({"type": "ctrl-cl", "index": "core-all"}, "options"),
+ Output({"type": "ctrl-cl", "index": "frmsize"}, "options"),
+ Output({"type": "ctrl-cl", "index": "frmsize"}, "value"),
+ Output({"type": "ctrl-cl", "index": "frmsize-all"}, "value"),
+ Output({"type": "ctrl-cl", "index": "frmsize-all"}, "options"),
+ Output({"type": "ctrl-cl", "index": "tsttype"}, "options"),
+ Output({"type": "ctrl-cl", "index": "tsttype"}, "value"),
+ Output({"type": "ctrl-cl", "index": "tsttype-all"}, "value"),
+ Output({"type": "ctrl-cl", "index": "tsttype-all"}, "options"),
+ Output({"type": "ctrl-btn", "index": "add-test"}, "disabled"),
+ Output("normalize", "value"),
+
+ State("store", "data"),
+ State({"type": "sel-cl", "index": ALL}, "value"),
+ State({"type": "cb-all-in-one", "index": ALL}, "value"),
+ State({"type": "cb-ignore-host", "index": ALL}, "value"),
+ State({"type": "telemetry-search-out", "index": ALL}, "children"),
+ State({"type": "plot-mod-telemetry", "index": ALL}, "is_open"),
+ State({"type": "telemetry-btn", "index": ALL}, "disabled"),
+ State({"type": "tm-container", "index": ALL}, "children"),
+ State({"type": "tm-list-metrics", "index": ALL}, "value"),
+ State({"type": "tele-cl", "index": ALL}, "value"),
+
+ Input("url", "href"),
+ Input({"type": "tm-dd", "index": ALL}, "value"),
+
+ Input("normalize", "value"),
+ Input({"type": "telemetry-search-in", "index": ALL}, "value"),
+ Input({"type": "telemetry-btn", "index": ALL}, "n_clicks"),
+ Input({"type": "tm-btn-remove", "index": ALL}, "n_clicks"),
+ Input({"type": "ctrl-dd", "index": ALL}, "value"),
+ Input({"type": "ctrl-cl", "index": ALL}, "value"),
+ Input({"type": "ctrl-btn", "index": ALL}, "n_clicks"),
+
+ prevent_initial_call=True
+ )
+ def _update_application(
+ store: dict,
+ lst_sel: list,
+ all_in_one: list,
+ ignore_host: list,
+ search_out: list,
+ is_open: list,
+ tm_btns_disabled: list,
+ tm_dd: list,
+ list_metrics: list,
+ cl_metrics: list,
+ href: str,
+ tm_dd_in: list,
+ *_
+ ) -> tuple:
+ """Update the application when the event is detected.
+ """
+
+ if store is None:
+ store = {
+ "control-panel": dict(),
+ "selected-tests": list(),
+ "trending-graphs": None,
+ "telemetry-data": dict(),
+ "selected-metrics": dict(),
+ "telemetry-panels": list(),
+ "telemetry-all-in-one": list(),
+ "telemetry-ignore-host": list(),
+ "telemetry-graphs": list(),
+ "url": str()
+ }
+
+ ctrl_panel = ControlPanel(
+ CP_PARAMS,
+ store.get("control-panel", dict())
+ )
+ store_sel = store["selected-tests"]
+ tm_data = store["telemetry-data"]
+ tm_user = store["selected-metrics"]
+ tm_panels = store["telemetry-panels"]
+ tm_all_in_one = store["telemetry-all-in-one"]
+ tm_ignore_host = store["telemetry-ignore-host"]
+
+ plotting_area_telemetry = no_update
+ on_draw = [False, False] # 0 --> trending, 1 --> telemetry
+
+ # Parse the url:
+ parsed_url = url_decode(href)
+ if parsed_url:
+ url_params = parsed_url["params"]
+ else:
+ url_params = None
+
+ if tm_user is None:
+ # Telemetry user data
+ # The data provided by user or result of user action
+ tm_user = {
+ # List of unique metrics:
+ "unique_metrics": list(),
+ # List of metrics selected by user:
+ "selected_metrics": list(),
+ # Labels from metrics selected by user (key: label name,
+ # value: list of all possible values):
+ "unique_labels": dict(),
+ # Labels selected by the user (subset of 'unique_labels'):
+ "selected_labels": dict(),
+ # All unique metrics with labels (output from the step 1)
+ # converted from pandas dataframe to dictionary.
+ "unique_metrics_with_labels": dict(),
+ # Metrics with labels selected by the user using dropdowns.
+ "selected_metrics_with_labels": dict()
+ }
+ tm = TelemetryData(store_sel) if store_sel else TelemetryData()
+
+ trigger = Trigger(callback_context.triggered)
+ if trigger.type == "url" and url_params:
+ telemetry = None
+ try:
+ store_sel = literal_eval(url_params["store_sel"][0])
+ normalize = literal_eval(url_params["norm"][0])
+ telemetry = literal_eval(url_params["telemetry"][0])
+ url_p = url_params.get("all-in-one", ["[[None]]"])
+ tm_all_in_one = literal_eval(url_p[0])
+ url_p = url_params.get("ignore-host", ["[[None]]"])
+ tm_ignore_host = literal_eval(url_p[0])
+ if not isinstance(telemetry, list):
+ telemetry = [telemetry, ]
+ except (KeyError, IndexError, AttributeError, ValueError):
+ pass
+ if store_sel:
+ last_test = store_sel[-1]
+ test = self._spec_tbs[last_test["dut"]]\
+ [last_test["area"]][last_test["test"]][last_test["phy"]]
+ ctrl_panel.set({
+ "dd-dut-val": last_test["dut"],
+ "dd-area-val": last_test["area"],
+ "dd-area-opt": [
+ {"label": label(v), "value": v} for v in sorted(
+ self._spec_tbs[last_test["dut"]].keys())
+ ],
+ "dd-area-dis": False,
+ "dd-test-val": last_test["test"],
+ "dd-test-opt": generate_options(
+ self._spec_tbs[last_test["dut"]]\
+ [last_test["area"]].keys()
+ ),
+ "dd-test-dis": False,
+ "dd-phy-val": last_test["phy"],
+ "dd-phy-opt": generate_options(
+ self._spec_tbs[last_test["dut"]][last_test["area"]]\
+ [last_test["test"]].keys()
+ ),
+ "dd-phy-dis": False,
+ "cl-core-opt": generate_options(test["core"]),
+ "cl-core-val": [last_test["core"].upper(), ],
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_ENABLED,
+ "cl-frmsize-opt": generate_options(test["frame-size"]),
+ "cl-frmsize-val": [last_test["framesize"].upper(), ],
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_ENABLED,
+ "cl-tsttype-opt": generate_options(test["test-type"]),
+ "cl-tsttype-val": [last_test["testtype"].upper(), ],
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_ENABLED,
+ "cl-normalize-val": normalize,
+ "btn-add-dis": False
+ })
+ store["trending-graphs"] = None
+ store["telemetry-graphs"] = list()
+ on_draw[0] = True
+ if telemetry:
+ tm = TelemetryData(store_sel)
+ tm.from_dataframe(self._data)
+ tm_data = tm.to_json()
+ tm.from_json(tm_data)
+ tm_panels = telemetry
+ on_draw[1] = True
+ elif trigger.type == "normalize":
+ ctrl_panel.set({"cl-normalize-val": trigger.value})
+ store["trending-graphs"] = None
+ on_draw[0] = True
+ elif trigger.type == "ctrl-dd":
+ if trigger.idx == "dut":
+ try:
+ dut = self._spec_tbs[trigger.value]
+ options = [{"label": label(v), "value": v} \
+ for v in sorted(dut.keys())]
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dd-dut-val": trigger.value,
+ "dd-area-val": str(),
+ "dd-area-opt": options,
+ "dd-area-dis": disabled,
+ "dd-test-val": str(),
+ "dd-test-opt": list(),
+ "dd-test-dis": True,
+ "dd-phy-val": str(),
+ "dd-phy-opt": list(),
+ "dd-phy-dis": True,
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True
+ })
+ if trigger.idx == "area":
+ try:
+ dut = ctrl_panel.get("dd-dut-val")
+ area = self._spec_tbs[dut][trigger.value]
+ options = generate_options(area.keys())
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dd-area-val": trigger.value,
+ "dd-test-val": str(),
+ "dd-test-opt": options,
+ "dd-test-dis": disabled,
+ "dd-phy-val": str(),
+ "dd-phy-opt": list(),
+ "dd-phy-dis": True,
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True
+ })
+ if trigger.idx == "test":
+ try:
+ dut = ctrl_panel.get("dd-dut-val")
+ area = ctrl_panel.get("dd-area-val")
+ test = self._spec_tbs[dut][area][trigger.value]
+ options = generate_options(test.keys())
+ disabled = False
+ except KeyError:
+ options = list()
+ disabled = True
+ ctrl_panel.set({
+ "dd-test-val": trigger.value,
+ "dd-phy-val": str(),
+ "dd-phy-opt": options,
+ "dd-phy-dis": disabled,
+ "cl-core-opt": list(),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_DISABLED,
+ "cl-frmsize-opt": list(),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_DISABLED,
+ "cl-tsttype-opt": list(),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_DISABLED,
+ "btn-add-dis": True
+ })
+ if trigger.idx == "phy":
+ dut = ctrl_panel.get("dd-dut-val")
+ area = ctrl_panel.get("dd-area-val")
+ test = ctrl_panel.get("dd-test-val")
+ if all((dut, area, test, trigger.value, )):
+ phy = self._spec_tbs[dut][area][test][trigger.value]
+ ctrl_panel.set({
+ "dd-phy-val": trigger.value,
+ "cl-core-opt": generate_options(phy["core"]),
+ "cl-core-val": list(),
+ "cl-core-all-val": list(),
+ "cl-core-all-opt": C.CL_ALL_ENABLED,
+ "cl-frmsize-opt": \
+ generate_options(phy["frame-size"]),
+ "cl-frmsize-val": list(),
+ "cl-frmsize-all-val": list(),
+ "cl-frmsize-all-opt": C.CL_ALL_ENABLED,
+ "cl-tsttype-opt": \
+ generate_options(phy["test-type"]),
+ "cl-tsttype-val": list(),
+ "cl-tsttype-all-val": list(),
+ "cl-tsttype-all-opt": C.CL_ALL_ENABLED,
+ "btn-add-dis": True
+ })
+ elif trigger.type == "ctrl-cl":
+ param = trigger.idx.split("-")[0]
+ if "-all" in trigger.idx:
+ c_sel, c_all, c_id = list(), trigger.value, "all"
+ else:
+ c_sel, c_all, c_id = trigger.value, list(), str()
+ val_sel, val_all = sync_checklists(
+ options=ctrl_panel.get(f"cl-{param}-opt"),
+ sel=c_sel,
+ all=c_all,
+ id=c_id
+ )
+ ctrl_panel.set({
+ f"cl-{param}-val": val_sel,
+ f"cl-{param}-all-val": val_all,
+ })
+ if all((ctrl_panel.get("cl-core-val"),
+ ctrl_panel.get("cl-frmsize-val"),
+ ctrl_panel.get("cl-tsttype-val"), )):
+ ctrl_panel.set({"btn-add-dis": False})
+ else:
+ ctrl_panel.set({"btn-add-dis": True})
+ elif trigger.type == "ctrl-btn":
+ tm_panels = list()
+ tm_all_in_one = list()
+ tm_ignore_host = list()
+ store["trending-graphs"] = None
+ store["telemetry-graphs"] = list()
+ on_draw = [True, True]
+ if trigger.idx == "add-test":
+ dut = ctrl_panel.get("dd-dut-val")
+ phy = ctrl_panel.get("dd-phy-val")
+ area = ctrl_panel.get("dd-area-val")
+ test = ctrl_panel.get("dd-test-val")
+ # Add selected test(s) to the list of tests in store:
+ if store_sel is None:
+ store_sel = list()
+ for core in ctrl_panel.get("cl-core-val"):
+ for framesize in ctrl_panel.get("cl-frmsize-val"):
+ for ttype in ctrl_panel.get("cl-tsttype-val"):
+ if dut == "trex":
+ core = str()
+ tid = "-".join((
+ dut,
+ phy.replace('af_xdp', 'af-xdp'),
+ area,
+ framesize.lower(),
+ core.lower(),
+ test,
+ ttype.lower()
+ ))
+ if tid not in [i["id"] for i in store_sel]:
+ store_sel.append({
+ "id": tid,
+ "dut": dut,
+ "phy": phy,
+ "area": area,
+ "test": test,
+ "framesize": framesize.lower(),
+ "core": core.lower(),
+ "testtype": ttype.lower()
+ })
+ store_sel = sorted(store_sel, key=lambda d: d["id"])
+ if C.CLEAR_ALL_INPUTS:
+ ctrl_panel.set(ctrl_panel.defaults)
+ elif trigger.idx == "rm-test" and lst_sel:
+ new_store_sel = list()
+ for idx, item in enumerate(store_sel):
+ if not lst_sel[idx]:
+ new_store_sel.append(item)
+ store_sel = new_store_sel
+ elif trigger.idx == "rm-test-all":
+ store_sel = list()
+ elif trigger.type == "telemetry-btn":
+ if trigger.idx in ("open", "back"):
+ tm.from_dataframe(self._data)
+ tm_data = tm.to_json()
+ tm_user["unique_metrics"] = tm.unique_metrics
+ tm_user["selected_metrics"] = list()
+ tm_user["unique_labels"] = dict()
+ tm_user["selected_labels"] = dict()
+ search_out = (
+ get_list_group_items(tm_user["unique_metrics"],
+ "tele-cl", False),
+ )
+ is_open = (True, False)
+ tm_btns_disabled[1], tm_btns_disabled[5] = False, True
+ elif trigger.idx == "select":
+ if any(cl_metrics):
+ tm.from_json(tm_data)
+ if not tm_user["selected_metrics"]:
+ tm_user["selected_metrics"] = \
+ tm_user["unique_metrics"]
+ metrics = [a for a, b in \
+ zip(tm_user["selected_metrics"], cl_metrics) if b]
+ tm_user["selected_metrics"] = metrics
+ tm_user["unique_labels"] = \
+ tm.get_selected_labels(metrics)
+ tm_user["unique_metrics_with_labels"] = \
+ tm.unique_metrics_with_labels
+ list_metrics[0] = tm.str_metrics
+ tm_dd[0] = _get_dd_container(tm_user["unique_labels"])
+ if list_metrics[0]:
+ tm_btns_disabled[1] = True
+ tm_btns_disabled[4] = False
+ is_open = (False, True)
+ else:
+ is_open = (True, False)
+ elif trigger.idx == "add":
+ tm.from_json(tm_data)
+ tm_panels.append(tm_user["selected_metrics_with_labels"])
+ tm_all_in_one.append(all_in_one)
+ tm_ignore_host.append(ignore_host)
+ is_open = (False, False)
+ tm_btns_disabled[1], tm_btns_disabled[5] = True, True
+ on_draw = [True, True]
+ elif trigger.idx == "cancel":
+ is_open = (False, False)
+ tm_btns_disabled[1], tm_btns_disabled[5] = True, True
+ elif trigger.idx == "rm-all":
+ tm_panels = list()
+ tm_all_in_one = list()
+ tm_ignore_host = list()
+ tm_user = None
+ is_open = (False, False)
+ tm_btns_disabled[1], tm_btns_disabled[5] = True, True
+ plotting_area_telemetry = C.PLACEHOLDER
+ elif trigger.type == "telemetry-search-in":
+ tm.from_metrics(tm_user["unique_metrics"])
+ tm_user["selected_metrics"] = \
+ tm.search_unique_metrics(trigger.value)
+ search_out = (get_list_group_items(
+ tm_user["selected_metrics"],
+ type="tele-cl",
+ colorize=False
+ ), )
+ is_open = (True, False)
+ elif trigger.type == "tm-dd":
+ tm.from_metrics_with_labels(
+ tm_user["unique_metrics_with_labels"]
+ )
+ selected = dict()
+ previous_itm = None
+ for itm in tm_dd_in:
+ if itm is None:
+ show_new = True
+ elif isinstance(itm, str):
+ show_new = False
+ selected[itm] = list()
+ elif isinstance(itm, list):
+ if previous_itm is not None:
+ selected[previous_itm] = itm
+ show_new = True
+ previous_itm = itm
+ tm_dd[0] = _get_dd_container(
+ tm_user["unique_labels"],
+ selected,
+ show_new
+ )
+ sel_metrics = tm.filter_selected_metrics_by_labels(selected)
+ tm_user["selected_metrics_with_labels"] = sel_metrics.to_dict()
+ if not sel_metrics.empty:
+ list_metrics[0] = tm.metrics_to_str(sel_metrics)
+ tm_btns_disabled[5] = False
+ else:
+ list_metrics[0] = str()
+ elif trigger.type == "tm-btn-remove":
+ del tm_panels[trigger.idx]
+ del tm_all_in_one[trigger.idx]
+ del tm_ignore_host[trigger.idx]
+ del store["telemetry-graphs"][trigger.idx]
+ tm.from_json(tm_data)
+ on_draw = [True, True]
+
+ new_url_params = {
+ "store_sel": store_sel,
+ "norm": ctrl_panel.get("cl-normalize-val")
+ }
+ if tm_panels:
+ new_url_params["telemetry"] = tm_panels
+ new_url_params["all-in-one"] = tm_all_in_one
+ new_url_params["ignore-host"] = tm_ignore_host
+
+ if on_draw[0]: # Trending
+ if store_sel:
+ lg_selected = get_list_group_items(store_sel, "sel-cl")
+ if store["trending-graphs"]:
+ graphs = store["trending-graphs"]
+ else:
+ graphs = graph_trending(
+ self._data,
+ store_sel,
+ self._graph_layout,
+ bool(ctrl_panel.get("cl-normalize-val"))
+ )
+ if graphs and graphs[0]:
+ store["trending-graphs"] = graphs
+ plotting_area_trending = \
+ Layout._plotting_area_trending(graphs)
+
+ # Telemetry
+ start_idx = len(store["telemetry-graphs"])
+ end_idx = len(tm_panels)
+ if not end_idx:
+ plotting_area_telemetry = C.PLACEHOLDER
+ elif on_draw[1] and (end_idx >= start_idx):
+ if len(tm_all_in_one) != end_idx:
+ tm_all_in_one = [[None], ] * end_idx
+ if len(tm_ignore_host) != end_idx:
+ tm_ignore_host = [[None], ] * end_idx
+ for idx in range(start_idx, end_idx):
+ store["telemetry-graphs"].append(graph_tm_trending(
+ tm.select_tm_trending_data(
+ tm_panels[idx],
+ ignore_host=bool(tm_ignore_host[idx][0])
+ ),
+ self._graph_layout,
+ bool(tm_all_in_one[idx][0])
+ ))
+ plotting_area_telemetry = \
+ Layout._plotting_area_telemetry(
+ store["telemetry-graphs"]
+ )
+ col_plotting_area = C.STYLE_ENABLED
+ row_card_sel_tests = C.STYLE_ENABLED
+ row_btns_sel_tests = C.STYLE_ENABLED
+ row_btns_add_tm = C.STYLE_ENABLED
+ else:
+ plotting_area_trending = no_update
+ plotting_area_telemetry = C.PLACEHOLDER
+ col_plotting_area = C.STYLE_DISABLED
+ row_card_sel_tests = C.STYLE_DISABLED
+ row_btns_sel_tests = C.STYLE_DISABLED
+ row_btns_add_tm = C.STYLE_DISABLED
+ lg_selected = no_update
+ store_sel = list()
+ tm_panels = list()
+ tm_all_in_one = list()
+ tm_ignore_host = list()
+ tm_user = None
+ else:
+ plotting_area_trending = no_update
+ col_plotting_area = no_update
+ row_card_sel_tests = no_update
+ row_btns_sel_tests = no_update
+ row_btns_add_tm = no_update
+ lg_selected = no_update
+
+ store["url"] = gen_new_url(parsed_url, new_url_params)
+ store["control-panel"] = ctrl_panel.panel
+ store["selected-tests"] = store_sel
+ store["telemetry-data"] = tm_data
+ store["selected-metrics"] = tm_user
+ store["telemetry-panels"] = tm_panels
+ store["telemetry-all-in-one"] = tm_all_in_one
+ store["telemetry-ignore-host"] = tm_ignore_host
+ ret_val = [
+ store,
+ plotting_area_trending,
+ plotting_area_telemetry,
+ col_plotting_area,
+ row_card_sel_tests,
+ row_btns_sel_tests,
+ row_btns_add_tm,
+ lg_selected,
+ search_out,
+ is_open,
+ tm_btns_disabled,
+ tm_dd,
+ list_metrics
+ ]
+ ret_val.extend(ctrl_panel.values)
+ return ret_val
+
+ @app.callback(
+ Output("plot-mod-url", "is_open"),
+ Output("mod-url", "children"),
+ State("store", "data"),
+ State("plot-mod-url", "is_open"),
+ Input("plot-btn-url", "n_clicks")
+ )
+ def toggle_plot_mod_url(store, is_open, n_clicks):
+ """Toggle the modal window with url.
+ """
+ if not store:
+ raise PreventUpdate
+
+ if n_clicks:
+ return not is_open, store.get("url", str())
+ return is_open, store["url"]
+
+ def _get_dd_container(
+ all_labels: dict,
+ selected_labels: dict=dict(),
+ show_new=True
+ ) -> list:
+ """Generate a container with dropdown selection boxes depenting on
+ the input data.
+
+ :param all_labels: A dictionary with unique labels and their
+ possible values.
+ :param selected_labels: A dictionalry with user selected lables and
+ their values.
+ :param show_new: If True, a dropdown selection box to add a new
+ label is displayed.
+ :type all_labels: dict
+ :type selected_labels: dict
+ :type show_new: bool
+ :returns: A list of dbc rows with dropdown selection boxes.
+ :rtype: list
+ """
+
+ def _row(
+ id: str,
+ lopts: list=list(),
+ lval: str=str(),
+ vopts: list=list(),
+ vvals: list=list()
+ ) -> dbc.Row:
+ """Generates a dbc row with dropdown boxes.
+
+ :param id: A string added to the dropdown ID.
+ :param lopts: A list of options for 'label' dropdown.
+ :param lval: Value of 'label' dropdown.
+ :param vopts: A list of options for 'value' dropdown.
+ :param vvals: A list of values for 'value' dropdown.
+ :type id: str
+ :type lopts: list
+ :type lval: str
+ :type vopts: list
+ :type vvals: list
+ :returns: dbc row with dropdown boxes.
+ :rtype: dbc.Row
+ """
+ children = list()
+ if lopts:
+ children.append(
+ dbc.Col(
+ width=6,
+ children=[
+ dcc.Dropdown(
+ id={
+ "type": "tm-dd",
+ "index": f"label-{id}"
+ },
+ placeholder="Select a label...",
+ optionHeight=20,
+ multi=False,
+ options=lopts,
+ value=lval if lval else None
+ )
+ ]
+ )
+ )
+ if vopts:
+ children.append(
+ dbc.Col(
+ width=6,
+ children=[
+ dcc.Dropdown(
+ id={
+ "type": "tm-dd",
+ "index": f"value-{id}"
+ },
+ placeholder="Select a value...",
+ optionHeight=20,
+ multi=True,
+ options=vopts,
+ value=vvals if vvals else None
+ )
+ ]
+ )
+ )
+
+ return dbc.Row(class_name="g-0 p-1", children=children)
+
+ container = list()
+
+ # Display rows with items in 'selected_labels'; label on the left,
+ # values on the right:
+ keys_left = list(all_labels.keys())
+ for idx, label in enumerate(selected_labels.keys()):
+ container.append(_row(
+ id=idx,
+ lopts=deepcopy(keys_left),
+ lval=label,
+ vopts=all_labels[label],
+ vvals=selected_labels[label]
+ ))
+ keys_left.remove(label)
+
+ # Display row with dd with labels on the left, right side is empty:
+ if show_new and keys_left:
+ container.append(_row(id="new", lopts=keys_left))
+
+ return container
+
+ @app.callback(
+ Output("metadata-tput-lat", "children"),
+ Output("metadata-hdrh-graph", "children"),
+ Output("offcanvas-metadata", "is_open"),
+ Input({"type": "graph", "index": ALL}, "clickData"),
+ prevent_initial_call=True
+ )
+ def _show_metadata_from_graphs(graph_data: dict) -> tuple:
+ """Generates the data for the offcanvas displayed when a particular
+ point in a graph is clicked on.
+
+ :param graph_data: The data from the clicked point in the graph.
+ :type graph_data: dict
+ :returns: The data to be displayed on the offcanvas and the
+ information to show the offcanvas.
+ :rtype: tuple(list, list, bool)
+ """
+
+ trigger = Trigger(callback_context.triggered)
+ if not trigger.value:
+ raise PreventUpdate
+
+ return show_trending_graph_data(
+ trigger, graph_data, self._graph_layout)
+
+ @app.callback(
+ Output("download-trending-data", "data"),
+ State("store", "data"),
+ Input("plot-btn-download", "n_clicks"),
+ Input({"type": "tm-btn-download", "index": ALL}, "n_clicks"),
+ prevent_initial_call=True
+ )
+ def _download_data(store: list, *_) -> dict:
+ """Download the data
+
+ :param store_sel: List of tests selected by user stored in the
+ browser.
+ :type store_sel: list
+ :returns: dict of data frame content (base64 encoded) and meta data
+ used by the Download component.
+ :rtype: dict
+ """
+
+ if not store:
+ raise PreventUpdate
+ if not store["selected-tests"]:
+ raise PreventUpdate
+
+ df = pd.DataFrame()
+
+ trigger = Trigger(callback_context.triggered)
+ if not trigger.value:
+ raise PreventUpdate
+
+ if trigger.type == "plot-btn-download":
+ data = list()
+ for itm in store["selected-tests"]:
+ sel_data = select_trending_data(self._data, itm)
+ if sel_data is None:
+ continue
+ data.append(sel_data)
+ df = pd.concat(data, ignore_index=True, copy=False)
+ file_name = C.TREND_DOWNLOAD_FILE_NAME
+ elif trigger.type == "tm-btn-download":
+ tm = TelemetryData(store["selected-tests"])
+ tm.from_json(store["telemetry-data"])
+ df = tm.select_tm_trending_data(
+ store["telemetry-panels"][trigger.idx]
+ )
+ file_name = C.TELEMETRY_DOWNLOAD_FILE_NAME
+ else:
+ raise PreventUpdate
+
+ return dcc.send_data_frame(df.to_csv, file_name)
+
+ @app.callback(
+ Output("offcanvas-documentation", "is_open"),
+ Input("btn-documentation", "n_clicks"),
+ State("offcanvas-documentation", "is_open")
+ )
+ def toggle_offcanvas_documentation(n_clicks, is_open):
+ if n_clicks:
+ return not is_open
+ return is_open
diff --git a/csit.infra.dash/app/cdash/trending/layout.yaml b/csit.infra.dash/app/cdash/trending/layout.yaml
new file mode 100644
index 0000000000..e4fcd29260
--- /dev/null
+++ b/csit.infra.dash/app/cdash/trending/layout.yaml
@@ -0,0 +1,201 @@
+plot-trending-tput:
+ autosize: True
+ showlegend: False
+ yaxis:
+ showticklabels: True
+ tickformat: ".3s"
+ title: "Throughput [pps|cps|rps|bps]"
+ hoverformat: ".5s"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 0
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-trending-bandwidth:
+ autosize: True
+ showlegend: False
+ yaxis:
+ showticklabels: True
+ tickformat: ".3s"
+ title: "Bandwidth [bps]"
+ hoverformat: ".5s"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 0
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-trending-lat:
+ autosize: True
+ showlegend: False
+ yaxis:
+ showticklabels: True
+ tickformat: ".3s"
+ title: "Average Latency at 50% PDR [us]"
+ hoverformat: ".5s"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 0
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: -1
+
+plot-hdrh-latency:
+ showlegend: True
+ legend:
+ traceorder: "normal"
+ orientation: "h"
+ xanchor: "left"
+ yanchor: "top"
+ x: 0
+ y: -0.25
+ bgcolor: "rgba(255, 255, 255, 0)"
+ bordercolor: "rgba(255, 255, 255, 0)"
+ xaxis:
+ type: "log"
+ title: "Percentile [%]"
+ autorange: True
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ tickvals: [1, 2, 1e1, 20, 1e2, 1e3, 1e4, 1e5, 1e6]
+ ticktext: [0, 50, 90, 95, 99, 99.9, 99.99, 99.999, 99.9999]
+ yaxis:
+ title: "One-Way Latency per Direction [us]"
+ gridcolor: "rgb(230, 230, 230)"
+ linecolor: "rgb(220, 220, 220)"
+ linewidth: 1
+ showgrid: True
+ showline: True
+ showticklabels: True
+ tickcolor: "rgb(220, 220, 220)"
+ autosize: True
+ paper_bgcolor: "white"
+ plot_bgcolor: "white"
+
+plot-trending-telemetry:
+ autosize: True
+ showlegend: True
+ yaxis:
+ showticklabels: True
+ tickformat: ".3s"
+ title: "Metric"
+ hoverformat: ".5s"
+ gridcolor: "rgb(238, 238, 238)"
+ linecolor: "rgb(238, 238, 238)"
+ showline: True
+ zeroline: False
+ tickcolor: "rgb(238, 238, 238)"
+ linewidth: 1
+ showgrid: True
+ xaxis:
+ title: 'Date [MMDD]'
+ type: "date"
+ autorange: True
+ fixedrange: False
+ showgrid: True
+ gridcolor: "rgb(238, 238, 238)"
+ showline: True
+ linecolor: "rgb(238, 238, 238)"
+ zeroline: False
+ linewidth: 1
+ showticklabels: True
+ tickcolor: "rgb(238, 238, 238)"
+ tickmode: "auto"
+ tickformat: "%m%d"
+ margin:
+ r: 20
+ b: 0
+ t: 5
+ l: 70
+ paper_bgcolor: "#fff"
+ plot_bgcolor: "#fff"
+ hoverlabel:
+ namelength: 50
+ legend:
+ orientation: "h"
+ y: -0.2
+ font:
+ size: 12
diff --git a/csit.infra.dash/app/cdash/trending/trending.py b/csit.infra.dash/app/cdash/trending/trending.py
new file mode 100644
index 0000000000..257e3de625
--- /dev/null
+++ b/csit.infra.dash/app/cdash/trending/trending.py
@@ -0,0 +1,52 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Instantiate the Trending Dash application.
+"""
+import dash
+import pandas as pd
+
+from ..utils.constants import Constants as C
+from .layout import Layout
+
+
+def init_trending(
+ server,
+ data_trending: pd.DataFrame
+ ) -> dash.Dash:
+ """Create a Plotly Dash dashboard.
+
+ :param server: Flask server.
+ :type server: Flask
+ :returns: Dash app server.
+ :rtype: Dash
+ """
+
+ dash_app = dash.Dash(
+ server=server,
+ routes_pathname_prefix=C.TREND_ROUTES_PATHNAME_PREFIX,
+ external_stylesheets=C.EXTERNAL_STYLESHEETS,
+ title=C.TREND_TITLE
+ )
+
+ layout = Layout(
+ app=dash_app,
+ data_trending=data_trending,
+ html_layout_file=C.HTML_LAYOUT_FILE,
+ graph_layout_file=C.TREND_GRAPH_LAYOUT_FILE,
+ tooltip_file=C.TOOLTIP_FILE
+ )
+ dash_app.index_string = layout.html_layout
+ dash_app.layout = layout.add_content()
+
+ return dash_app.server
diff --git a/csit.infra.dash/app/cdash/utils/__init__.py b/csit.infra.dash/app/cdash/utils/__init__.py
new file mode 100644
index 0000000000..c6a5f639fe
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/utils/anomalies.py b/csit.infra.dash/app/cdash/utils/anomalies.py
new file mode 100644
index 0000000000..3deece2e04
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/anomalies.py
@@ -0,0 +1,69 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Functions used by Dash applications to detect anomalies.
+"""
+
+from numpy import isnan
+
+from ..jumpavg import classify
+
+
+def classify_anomalies(data):
+ """Process the data and return anomalies and trending values.
+
+ Gather data into groups with average as trend value.
+ Decorate values within groups to be normal,
+ the first value of changed average as a regression, or a progression.
+
+ :param data: Full data set with unavailable samples replaced by nan.
+ :type data: OrderedDict
+ :returns: Classification and trend values
+ :rtype: 3-tuple, list of strings, list of floats and list of floats
+ """
+ # NaN means something went wrong.
+ # Use 0.0 to cause that being reported as a severe regression.
+ bare_data = [0.0 if isnan(sample) else sample for sample in data.values()]
+ # TODO: Make BitCountingGroupList a subclass of list again?
+ group_list = classify(bare_data).group_list
+ group_list.reverse() # Just to use .pop() for FIFO.
+ classification = list()
+ avgs = list()
+ stdevs = list()
+ active_group = None
+ values_left = 0
+ avg = 0.0
+ stdv = 0.0
+ for sample in data.values():
+ if isnan(sample):
+ classification.append("outlier")
+ avgs.append(sample)
+ stdevs.append(sample)
+ continue
+ if values_left < 1 or active_group is None:
+ values_left = 0
+ while values_left < 1: # Ignore empty groups (should not happen).
+ active_group = group_list.pop()
+ values_left = len(active_group.run_list)
+ avg = active_group.stats.avg
+ stdv = active_group.stats.stdev
+ classification.append(active_group.comment)
+ avgs.append(avg)
+ stdevs.append(stdv)
+ values_left -= 1
+ continue
+ classification.append("normal")
+ avgs.append(avg)
+ stdevs.append(stdv)
+ values_left -= 1
+ return classification, avgs, stdevs
diff --git a/csit.infra.dash/app/cdash/utils/constants.py b/csit.infra.dash/app/cdash/utils/constants.py
new file mode 100644
index 0000000000..840766488a
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/constants.py
@@ -0,0 +1,469 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Constants used in CDash.
+
+"Constant" means a value that keeps its value since initialization. The value
+does not need to be hard coded here, but can be read from environment variables.
+"""
+
+import logging
+
+from dash import html
+
+
+class Constants:
+ """Constants used in CDash.
+ """
+
+ ############################################################################
+ # General, application wide constants.
+
+ # Logging settings.
+ LOG_LEVEL = logging.INFO
+ LOG_FORMAT = "%(asctime)s: %(levelname)s: %(message)s"
+ LOG_DATE_FORMAT = "%Y/%m/%d %H:%M:%S"
+
+ # The application title.
+ TITLE = "FD.io CSIT"
+ BRAND = "CSIT-Dash"
+
+ # The application description.
+ DESCRIPTION = "Performance Dashboard"
+
+ # External stylesheets.
+ EXTERNAL_STYLESHEETS = ["/static/dist/css/bootstrap.css", ]
+
+ # URL to Jenkins
+ URL_JENKINS = "https://jenkins.fd.io/job/"
+
+ # URL to logs
+ URL_LOGS = "https://s3-logs.fd.io/vex-yul-rot-jenkins-1/"
+
+ # URL to the documentation
+ URL_DOC_TRENDING = "https://csit.fd.io/cdocs/methodology/trending/analysis/"
+ URL_DOC_REL_NOTES = "https://csit.fd.io/cdocs/release_notes/current/"
+
+ # Path and name of the file specifying the HTML layout of the dash
+ # application.
+ MAIN_HTML_LAYOUT_FILE = "base_layout.jinja2"
+
+ # Path and name of the file specifying the HTML layout of the dash
+ # application.
+ HTML_LAYOUT_FILE = "cdash/templates/dash_layout.jinja2"
+
+ # Application root.
+ APPLICATIN_ROOT = "/"
+
+ # Data to be downloaded from the parquets specification file.
+ DATA_SPEC_FILE = "cdash/data/data.yaml"
+
+ # Path to schemas to use when reading data from the parquet.
+ PATH_TO_SCHEMAS = "cdash/data/_metadata/"
+
+ # The file with tooltips.
+ TOOLTIP_FILE = "cdash/utils/tooltips.yaml"
+
+ # Maximal value of TIME_PERIOD for data read from the parquets in days.
+ # Do not change without a good reason.
+ MAX_TIME_PERIOD = 250
+
+ # It defines the time period for data read from the parquets in days from
+ # now back to the past.
+ # TIME_PERIOD = None - means all data (max MAX_TIME_PERIOD days) is read.
+ # TIME_PERIOD = MAX_TIME_PERIOD - is the default value
+ TIME_PERIOD = MAX_TIME_PERIOD # [days]
+
+ ############################################################################
+ # General, application wide, layout affecting constants.
+
+ # Add a time delay (in ms) to the spinner being shown
+ SPINNER_DELAY = 500
+
+ # If True, clear all inputs in control panel when button "ADD SELECTED" is
+ # pressed.
+ CLEAR_ALL_INPUTS = False
+
+ # The element is disabled.
+ STYLE_DISABLED = {"visibility": "hidden"}
+
+ # The element is enabled and visible.
+ STYLE_ENABLED = {"visibility": "visible"}
+
+ # The element is not displayed.
+ STYLE_DONT_DISPLAY = {"display": "none"}
+
+ # The element is displaed.
+ STYLE_DISPLAY = {"display": "flex"}
+
+ # Checklist "All" is disabled.
+ CL_ALL_DISABLED = [
+ {
+ "label": "All",
+ "value": "all",
+ "disabled": True
+ }
+ ]
+
+ # Checklist "All" is enabled, visible and unchecked.
+ CL_ALL_ENABLED = [
+ {
+ "label": "All",
+ "value": "all",
+ "disabled": False
+ }
+ ]
+
+ # Placeholder for any element in the layout.
+ PLACEHOLDER = html.Nobr("")
+
+ # List of drivers used in CSIT.
+ DRIVERS = ("avf", "af-xdp", "rdma", "dpdk", "mlx5")
+
+ # Labels for input elements (dropdowns, ...).
+ LABELS = {
+ "dpdk": "DPDK",
+ "container_memif": "LXC/DRC Container Memif",
+ "crypto": "IPSec IPv4 Routing",
+ "gso": "GSO",
+ "ip4": "IPv4 Routing",
+ "ip4_tunnels": "IPv4 Tunnels",
+ "ip6": "IPv6 Routing",
+ "ip6_tunnels": "IPv6 Tunnels",
+ "l2": "L2 Ethernet Switching",
+ "lb": "Load Balancer",
+ "srv6": "SRv6 Routing",
+ "vm_vhost": "VMs vhost-user",
+ "nfv_density.dcr_memif.chain_ipsec": "CNF Service Chains Routing IPSec",
+ "nfv_density.vm_vhost.chain_dot1qip4vxlan":"VNF Service Chains Tunnels",
+ "nfv_density.vm_vhost.chain": "VNF Service Chains Routing",
+ "nfv_density.dcr_memif.pipeline": "CNF Service Pipelines Routing",
+ "nfv_density.dcr_memif.chain": "CNF Service Chains Routing",
+ "hoststack": "Hoststack",
+ "flow": "Flow",
+ "l2bd": "L2 Bridge Domain",
+ "crypto.ethip4": "IPSec IPv4 Routing",
+ "crypto.ethip6": "IPSec IPv6 Routing",
+ "interfaces": "Interfaces",
+ "ip4_tunnels.lisp": "IPv4 Tunnels LISP",
+ "ip6_tunnels.lisp": "IPv6 Tunnels LISP",
+ "l2patch": "L2 Patch",
+ "l2xc": "L2 Cross Connect",
+ "vm_vhost.ethip4": "VMs vhost-user IPv4 Routing",
+ "vm_vhost.ethip6": "VMs vhost-user IPv6 Routing"
+ }
+
+ # URL style.
+ URL_STYLE = {
+ "background-color": "#d2ebf5",
+ "border-color": "#bce1f1",
+ "color": "#135d7c"
+ }
+
+ ############################################################################
+ # General, normalization constants.
+
+ NORM_FREQUENCY = 2.0 # [GHz]
+ FREQUENCY = { # [GHz]
+ "1n-aws": 3.400,
+ "2n-aws": 3.400,
+ "2n-c6in": 3.500,
+ "2n-clx": 2.300,
+ "2n-icx": 2.600,
+ "2n-spr": 2.800,
+ "2n-tx2": 2.500,
+ "2n-zn2": 2.900,
+ "3n-alt": 3.000,
+ "3n-icx": 2.600,
+ "3n-icxd": 2.000,
+ "3n-snr": 2.200,
+ "3n-tsh": 2.200,
+ "3na-spr": 2.800,
+ "3nb-spr": 2.800
+ }
+
+ ############################################################################
+ # General, plots and tables constants.
+
+ PLOT_COLORS = (
+ "#1A1110", "#DA2647", "#214FC6", "#01786F", "#BD8260", "#FFD12A",
+ "#A6E7FF", "#738276", "#C95A49", "#FC5A8D", "#CEC8EF", "#391285",
+ "#6F2DA8", "#FF878D", "#45A27D", "#FFD0B9", "#FD5240", "#DB91EF",
+ "#44D7A8", "#4F86F7", "#84DE02", "#FFCFF1", "#614051"
+ )
+
+ # Trending, anomalies.
+ ANOMALY_COLOR = {
+ "regression": 0.0,
+ "normal": 0.5,
+ "progression": 1.0
+ }
+
+ COLORSCALE_TPUT = [
+ [0.00, "red"],
+ [0.33, "red"],
+ [0.33, "white"],
+ [0.66, "white"],
+ [0.66, "green"],
+ [1.00, "green"]
+ ]
+
+ TICK_TEXT_TPUT = ["Regression", "Normal", "Progression"]
+
+ COLORSCALE_LAT = [
+ [0.00, "green"],
+ [0.33, "green"],
+ [0.33, "white"],
+ [0.66, "white"],
+ [0.66, "red"],
+ [1.00, "red"]
+ ]
+
+ TICK_TEXT_LAT = ["Progression", "Normal", "Regression"]
+
+ # Access to the results.
+ VALUE = {
+ "mrr": "result_receive_rate_rate_avg",
+ "ndr": "result_ndr_lower_rate_value",
+ "pdr": "result_pdr_lower_rate_value",
+ "mrr-bandwidth": "result_receive_rate_bandwidth_avg",
+ "ndr-bandwidth": "result_ndr_lower_bandwidth_value",
+ "pdr-bandwidth": "result_pdr_lower_bandwidth_value",
+ "latency": "result_latency_forward_pdr_50_avg",
+ "hoststack-cps": "result_rate_value",
+ "hoststack-rps": "result_rate_value",
+ "hoststack-cps-bandwidth": "result_bandwidth_value",
+ "hoststack-rps-bandwidth": "result_bandwidth_value",
+ "hoststack-bps": "result_bandwidth_value",
+ "hoststack-latency": "result_latency_value",
+ "soak": "result_critical_rate_lower_rate_value",
+ "soak-bandwidth": "result_critical_rate_lower_bandwidth_value"
+ }
+
+ VALUE_ITER = {
+ "mrr": "result_receive_rate_rate_values",
+ "ndr": "result_ndr_lower_rate_value",
+ "pdr": "result_pdr_lower_rate_value",
+ "mrr-bandwidth": "result_receive_rate_bandwidth_avg",
+ "ndr-bandwidth": "result_ndr_lower_bandwidth_value",
+ "pdr-bandwidth": "result_pdr_lower_bandwidth_value",
+ "latency": "result_latency_forward_pdr_50_avg",
+ "hoststack-cps": "result_rate_value",
+ "hoststack-rps": "result_rate_value",
+ "hoststack-cps-bandwidth": "result_bandwidth_value",
+ "hoststack-rps-bandwidth": "result_bandwidth_value",
+ "hoststack-bps": "result_bandwidth_value",
+ "hoststack-latency": "result_latency_value",
+ "soak": "result_critical_rate_lower_rate_value",
+ "soak-bandwidth": "result_critical_rate_lower_bandwidth_value"
+ }
+
+ UNIT = {
+ "mrr": "result_receive_rate_rate_unit",
+ "ndr": "result_ndr_lower_rate_unit",
+ "pdr": "result_pdr_lower_rate_unit",
+ "mrr-bandwidth": "result_receive_rate_bandwidth_unit",
+ "ndr-bandwidth": "result_ndr_lower_bandwidth_unit",
+ "pdr-bandwidth": "result_pdr_lower_bandwidth_unit",
+ "latency": "result_latency_forward_pdr_50_unit",
+ "hoststack-cps": "result_rate_unit",
+ "hoststack-rps": "result_rate_unit",
+ "hoststack-cps-bandwidth": "result_bandwidth_unit",
+ "hoststack-rps-bandwidth": "result_bandwidth_unit",
+ "hoststack-bps": "result_bandwidth_unit",
+ "hoststack-latency": "result_latency_unit",
+ "soak": "result_critical_rate_lower_rate_unit",
+ "soak-bandwidth": "result_critical_rate_lower_bandwidth_unit"
+ }
+
+ TESTS_WITH_BANDWIDTH = (
+ "ndr",
+ "pdr",
+ "mrr",
+ "hoststack-cps",
+ "hoststack-rps",
+ "soak"
+ )
+ TESTS_WITH_LATENCY = (
+ "pdr",
+ "hoststack-cps",
+ "hoststack-rps"
+ )
+
+ # Latencies.
+ LAT_HDRH = ( # Do not change the order
+ "result_latency_forward_pdr_0_hdrh",
+ "result_latency_reverse_pdr_0_hdrh",
+ "result_latency_forward_pdr_10_hdrh",
+ "result_latency_reverse_pdr_10_hdrh",
+ "result_latency_forward_pdr_50_hdrh",
+ "result_latency_reverse_pdr_50_hdrh",
+ "result_latency_forward_pdr_90_hdrh",
+ "result_latency_reverse_pdr_90_hdrh",
+ )
+
+ # This value depends on latency stream rate (9001 pps) and duration (5s).
+ # Keep it slightly higher to ensure rounding errors to not remove tick mark.
+ PERCENTILE_MAX = 99.999501
+
+ GRAPH_LAT_HDRH_DESC = {
+ "result_latency_forward_pdr_0_hdrh": "No-load.",
+ "result_latency_reverse_pdr_0_hdrh": "No-load.",
+ "result_latency_forward_pdr_10_hdrh": "Low-load, 10% PDR.",
+ "result_latency_reverse_pdr_10_hdrh": "Low-load, 10% PDR.",
+ "result_latency_forward_pdr_50_hdrh": "Mid-load, 50% PDR.",
+ "result_latency_reverse_pdr_50_hdrh": "Mid-load, 50% PDR.",
+ "result_latency_forward_pdr_90_hdrh": "High-load, 90% PDR.",
+ "result_latency_reverse_pdr_90_hdrh": "High-load, 90% PDR."
+ }
+
+ # Operators used to filter data in comparison tables.
+ OPERATORS = (
+ ("contains ", ),
+ ("lt ", "<"),
+ ("gt ", ">"),
+ ("eq ", "="),
+ ("ge ", ">="),
+ ("le ", "<="),
+ ("ne ", "!="),
+ ("datestartswith ", )
+ )
+
+ ############################################################################
+ # News.
+
+ # The title.
+ NEWS_TITLE = "Failures and Anomalies"
+
+ # The pathname prefix for the application.
+ NEWS_ROUTES_PATHNAME_PREFIX = "/news/"
+
+ # Time period for regressions and progressions.
+ NEWS_TIME_PERIOD = TIME_PERIOD # [days]
+
+ # Time periods for summary tables.
+ NEWS_LAST = 1 # [days]
+ NEWS_SHORT = 7 # [days]
+ NEWS_LONG = NEWS_TIME_PERIOD # [days]
+
+ ############################################################################
+ # Report.
+
+ # The title.
+ REPORT_TITLE = "Per Release Performance"
+
+ # The pathname prefix for the application.
+ REPORT_ROUTES_PATHNAME_PREFIX = "/report/"
+
+ # Layout of plot.ly graphs.
+ REPORT_GRAPH_LAYOUT_FILE = "cdash/report/layout.yaml"
+
+ # Default name of downloaded file with selected data.
+ REPORT_DOWNLOAD_FILE_NAME = "iterative_data.csv"
+
+ ############################################################################
+ # Comparisons.
+
+ # The title.
+ COMP_TITLE = "Per Release Performance Comparisons"
+
+ # The pathname prefix for the application.
+ COMP_ROUTES_PATHNAME_PREFIX = "/comparisons/"
+
+ # Default name of downloaded file with selected data.
+ COMP_DOWNLOAD_FILE_NAME = "comparison_data.csv"
+
+ # This parameter specifies the method to use for estimating the percentile.
+ # Possible values:
+ # - inverted_cdf
+ # - averaged_inverted_cdf
+ # - closest_observation
+ # - interpolated_inverted_cdf
+ # - hazen
+ # - weibull
+ # - linear (default)
+ # - median_unbiased
+ # - normal_unbiased
+ COMP_PERCENTILE_METHOD = "linear"
+
+ # Extreme or mild outlier?
+ OUTLIER_EXTREME = 3
+ OUTLIER_MILD = 1.5
+ COMP_OUTLIER_TYPE = OUTLIER_EXTREME
+
+ ############################################################################
+ # Statistics.
+
+ # The title.
+ STATS_TITLE = "Test Job Statistics"
+
+ # The pathname prefix for the application.
+ STATS_ROUTES_PATHNAME_PREFIX = "/stats/"
+
+ # Layout of plot.ly graphs.
+ STATS_GRAPH_LAYOUT_FILE = "cdash/stats/layout.yaml"
+
+ # The default job displayed when the page is loaded first time.
+ STATS_DEFAULT_JOB = "csit-vpp-perf-mrr-daily-master-2n-icx"
+
+ # Default name of downloaded file with selected data.
+ STATS_DOWNLOAD_FILE_NAME = "stats.csv"
+
+ # The width of the bar in the graph in miliseconds.
+ STATS_BAR_WIDTH_DAILY = 1000 * 3600 * 15
+ STATS_BAR_WIDTH_WEEKLY = 1000 * 3600 * 24
+
+ ############################################################################
+ # Trending.
+
+ # The title.
+ TREND_TITLE = "Performance Trending"
+
+ # The pathname prefix for the application.
+ TREND_ROUTES_PATHNAME_PREFIX = "/trending/"
+
+ # Layout of plot.ly graphs.
+ TREND_GRAPH_LAYOUT_FILE = "cdash/trending/layout.yaml"
+
+ # Default name of downloaded file with selected data.
+ TREND_DOWNLOAD_FILE_NAME = "trending_data.csv"
+ TELEMETRY_DOWNLOAD_FILE_NAME = "telemetry_data.csv"
+
+ ############################################################################
+ # Coverage data.
+
+ # The title.
+ COVERAGE_TITLE = "Per Release Coverage Data"
+
+ # The pathname prefix for the application.
+ COVERAGE_ROUTES_PATHNAME_PREFIX = "/coverage/"
+
+ # Default name of downloaded file with selected data.
+ COVERAGE_DOWNLOAD_FILE_NAME = "coverage_data.csv"
+
+ ############################################################################
+ # Search tests.
+
+ # The title.
+ SEARCH_TITLE = "Search Tests"
+
+ # The pathname prefix for the application.
+ SEARCH_ROUTES_PATHNAME_PREFIX = "/search/"
+
+ # Layout of plot.ly graphs.
+ SEARCH_GRAPH_LAYOUT_FILE = "cdash/search/layout.yaml"
+
+ # Default name of downloaded file with selected data.
+ SEARCH_DOWNLOAD_FILE_NAME = "search_data.csv"
+
+ ############################################################################
diff --git a/csit.infra.dash/app/cdash/utils/control_panel.py b/csit.infra.dash/app/cdash/utils/control_panel.py
new file mode 100644
index 0000000000..3da44e3901
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/control_panel.py
@@ -0,0 +1,87 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A module implementing the control panel data structure.
+"""
+
+from copy import deepcopy
+from typing import Any
+
+class ControlPanel:
+ """A class representing the control panel.
+ """
+
+ def __init__(self, params: dict, panel: dict) -> None:
+ """Initialisation of the control pannel by default values. If
+ particular values are provided (parameter "panel") they are set
+ afterwards.
+
+ :param params: Default values to be set to the control panel. This
+ dictionary also defines the full set of the control panel's
+ parameters and their order.
+ :param panel: Custom values to be set to the control panel.
+ :type params: dict
+ :type panel: dict
+ """
+
+ if not params:
+ raise ValueError("The params must be defined.")
+ self._panel = deepcopy(params)
+ if panel:
+ for key in panel:
+ if key in self._panel:
+ self._panel[key] = panel[key]
+ else:
+ raise AttributeError(
+ f"The parameter {key} is not defined in the list of "
+ f"parameters."
+ )
+
+ @property
+ def panel(self) -> dict:
+ return self._panel
+
+ @property
+ def values(self) -> tuple:
+ """Returns the values from the Control panel as a tuple.
+
+ :returns: The values from the Control panel.
+ :rtype: tuple
+ """
+ return tuple(self._panel.values())
+
+ def set(self, kwargs: dict=dict()) -> None:
+ """Set the values of the Control panel.
+
+ :param kwargs: key - value pairs to be set.
+ :type kwargs: dict
+ :raises KeyError: If the key in kwargs is not present in the Control
+ panel.
+ """
+ for key, val in kwargs.items():
+ if key in self._panel:
+ self._panel[key] = val
+ else:
+ raise KeyError(f"The key {key} is not defined.")
+
+ def get(self, key: str) -> Any:
+ """Returns the value of a key from the Control panel.
+
+ :param key: The key which value should be returned.
+ :type key: str
+ :returns: The value of the key.
+ :rtype: any
+ :raises KeyError: If the key in kwargs is not present in the Control
+ panel.
+ """
+ return self._panel[key]
diff --git a/csit.infra.dash/app/cdash/utils/telemetry_data.py b/csit.infra.dash/app/cdash/utils/telemetry_data.py
new file mode 100644
index 0000000000..9975874d96
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/telemetry_data.py
@@ -0,0 +1,362 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A module implementing the parsing of OpenMetrics data and elementary
+operations with it.
+"""
+
+
+import binascii
+import zlib
+import pandas as pd
+
+from ..trending.graphs import select_trending_data
+
+
+class TelemetryData:
+ """A class to store and manipulate the telemetry data.
+ """
+
+ def __init__(self, tests: list=list()) -> None:
+ """Initialize the object.
+
+ :param in_data: Input data.
+ :param tests: List of selected tests.
+ :type in_data: pandas.DataFrame
+ :type tests: list
+ """
+
+ self._tests = tests
+ self._data = None
+ self._unique_metrics = list()
+ self._unique_metrics_labels = pd.DataFrame()
+ self._selected_metrics_labels = pd.DataFrame()
+
+ def from_dataframe(self, in_data: pd.DataFrame=pd.DataFrame()) -> None:
+ """Read the input from pandas DataFrame.
+
+ This method must be called at the beginning to create all data
+ structures.
+ """
+
+ if in_data.empty:
+ return
+
+ metrics = set() # A set of unique metrics
+
+ # Create a dataframe with metrics for selected tests:
+ lst_items = list()
+ for itm in self._tests:
+ sel_data = select_trending_data(in_data, itm)
+ if sel_data is not None:
+ sel_data["test_name"] = itm["id"]
+ lst_items.append(sel_data)
+ df = pd.concat(lst_items, ignore_index=True, copy=False)
+
+ # Use only neccessary data:
+ df = df[[
+ "job",
+ "build",
+ "dut_type",
+ "dut_version",
+ "start_time",
+ "passed",
+ "test_name",
+ "test_type",
+ "result_receive_rate_rate_avg",
+ "result_receive_rate_rate_stdev",
+ "result_receive_rate_rate_unit",
+ "result_pdr_lower_rate_value",
+ "result_pdr_lower_rate_unit",
+ "result_ndr_lower_rate_value",
+ "result_ndr_lower_rate_unit",
+ "telemetry"
+ ]]
+ # Transform metrics from strings to dataframes:
+ lst_telemetry = list()
+ for _, row in df.iterrows():
+ d_telemetry = {
+ "metric": list(),
+ "labels": list(), # list of tuple(label, value)
+ "value": list(),
+ "timestamp": list()
+ }
+
+ # If there is no telemetry data, use empty dictionary
+ if row["telemetry"] is None or isinstance(row["telemetry"], float):
+ lst_telemetry.append(pd.DataFrame(data=d_telemetry))
+ continue
+
+ # Read telemetry data
+ # - list of uncompressed strings List[str, ...], or
+ # - list with only one compressed string List[str]
+ try:
+ tm_data = zlib.decompress(
+ binascii.a2b_base64(row["telemetry"][0].encode())
+ ).decode().split("\n")
+ except (binascii.Error, zlib.error, AttributeError, IndexError):
+ tm_data = row["telemetry"]
+
+ # Pre-process telemetry data
+ for itm in tm_data:
+ itm_lst = itm.replace("'", "").rsplit(" ", maxsplit=2)
+ metric, labels = itm_lst[0].split("{")
+ d_telemetry["metric"].append(metric)
+ d_telemetry["labels"].append(
+ [tuple(x.split("=")) for x in labels[:-1].split(",")]
+ )
+ d_telemetry["value"].append(itm_lst[1])
+ d_telemetry["timestamp"].append(itm_lst[2])
+
+ metrics.update(d_telemetry["metric"])
+ lst_telemetry.append(pd.DataFrame(data=d_telemetry))
+ df["telemetry"] = lst_telemetry
+
+ self._data = df
+ self._unique_metrics = sorted(metrics)
+
+ def from_json(self, in_data: dict) -> None:
+ """Read the input data from json.
+ """
+
+ df = pd.read_json(in_data)
+ lst_telemetry = list()
+ metrics = set() # A set of unique metrics
+ for _, row in df.iterrows():
+ telemetry = pd.DataFrame(row["telemetry"])
+ lst_telemetry.append(telemetry)
+ metrics.update(telemetry["metric"].to_list())
+ df["telemetry"] = lst_telemetry
+
+ self._data = df
+ self._unique_metrics = sorted(metrics)
+
+ def from_metrics(self, in_data: set) -> None:
+ """Read only the metrics.
+ """
+ self._unique_metrics = in_data
+
+ def from_metrics_with_labels(self, in_data: dict) -> None:
+ """Read only metrics with labels.
+ """
+ self._unique_metrics_labels = pd.DataFrame.from_dict(in_data)
+
+ def to_json(self) -> str:
+ """Return the data transformed from dataframe to json.
+
+ :returns: Telemetry data transformed to a json structure.
+ :rtype: dict
+ """
+ return self._data.to_json()
+
+ @property
+ def unique_metrics(self) -> list:
+ """Return a set of unique metrics.
+
+ :returns: A set of unique metrics.
+ :rtype: set
+ """
+ return self._unique_metrics
+
+ @property
+ def unique_metrics_with_labels(self) -> dict:
+ """
+ """
+ return self._unique_metrics_labels.to_dict()
+
+ def get_selected_labels(self, metrics: list) -> dict:
+ """Return a dictionary with labels (keys) and all their possible values
+ (values) for all selected 'metrics'.
+
+ :param metrics: List of metrics we are interested in.
+ :type metrics: list
+ :returns: A dictionary with labels and all their possible values.
+ :rtype: dict
+ """
+
+ lst_labels = list()
+ tmp_labels = dict()
+ for _, row in self._data.iterrows():
+ telemetry = row["telemetry"]
+ for itm in metrics:
+ df = telemetry.loc[(telemetry["metric"] == itm)]
+ lst_labels.append(df)
+ for _, tm in df.iterrows():
+ for label in tm["labels"]:
+ if label[0] not in tmp_labels:
+ tmp_labels[label[0]] = set()
+ tmp_labels[label[0]].add(label[1])
+
+ df_labels = pd.concat(lst_labels, ignore_index=True, copy=False)
+ selected_labels = dict()
+ for key in sorted(tmp_labels):
+ selected_labels[key] = sorted(tmp_labels[key])
+
+ self._unique_metrics_labels = df_labels[["metric", "labels"]].\
+ loc[df_labels[["metric", "labels"]].astype(str).\
+ drop_duplicates().index]
+
+ return selected_labels
+
+ @property
+ def str_metrics(self) -> str:
+ """Returns all unique metrics as a string.
+ """
+ return TelemetryData.metrics_to_str(self._unique_metrics_labels)
+
+ @staticmethod
+ def metrics_to_str(in_data: pd.DataFrame) -> str:
+ """Convert metrics from pandas dataframe to string. Metrics in string
+ are separated by '\n'.
+
+ :param in_data: Metrics to be converted to a string.
+ :type in_data: pandas.DataFrame
+ :returns: Metrics as a string.
+ :rtype: str
+ """
+ metrics = str()
+ for _, row in in_data.iterrows():
+ labels = ','.join([f"{itm[0]}='{itm[1]}'" for itm in row["labels"]])
+ metrics += f"{row['metric']}{{{labels}}}\n"
+ return metrics[:-1]
+
+ def search_unique_metrics(self, string: str) -> list:
+ """Return a list of metrics which name includes the given string.
+
+ :param string: A string which must be in the name of metric.
+ :type string: str
+ :returns: A list of metrics which name includes the given string.
+ :rtype: list
+ """
+ return [itm for itm in self._unique_metrics if string in itm]
+
+ def filter_selected_metrics_by_labels(
+ self,
+ selection: dict
+ ) -> pd.DataFrame:
+ """Filter selected unique metrics by labels and their values.
+
+ :param selection: Labels and their values specified by the user.
+ :type selection: dict
+ :returns: Pandas dataframe with filtered metrics.
+ :rtype: pandas.DataFrame
+ """
+
+ def _is_selected(labels: list, sel: dict) -> bool:
+ """Check if the provided 'labels' are selected by the user.
+
+ :param labels: List of labels and their values from a metric. The
+ items in this lists are two-item-lists whre the first item is
+ the label and the second one is its value.
+ :param sel: User selection. The keys are the selected lables and the
+ values are lists with label values.
+ :type labels: list
+ :type sel: dict
+ :returns: True if the 'labels' are selected by the user.
+ :rtype: bool
+ """
+ passed = list()
+ labels = dict(labels)
+ for key in sel.keys():
+ if key in list(labels.keys()):
+ if sel[key]:
+ passed.append(labels[key] in sel[key])
+ else:
+ passed.append(True)
+ else:
+ passed.append(False)
+ return bool(passed and all(passed))
+
+ self._selected_metrics_labels = pd.DataFrame()
+ lst_items = list()
+ for _, row in self._unique_metrics_labels.iterrows():
+ if _is_selected(row["labels"], selection):
+ lst_items.append(row.to_frame().T)
+ self._selected_metrics_labels = \
+ pd.concat(lst_items, ignore_index=True, axis=0, copy=False)
+ return self._selected_metrics_labels
+
+ def select_tm_trending_data(
+ self,
+ selection: dict,
+ ignore_host: bool = False
+ ) -> pd.DataFrame:
+ """Select telemetry data for trending based on user's 'selection'.
+
+ The output dataframe includes these columns:
+ - "job",
+ - "build",
+ - "dut_type",
+ - "dut_version",
+ - "start_time",
+ - "passed",
+ - "test_name",
+ - "test_id",
+ - "test_type",
+ - "result_receive_rate_rate_avg",
+ - "result_receive_rate_rate_stdev",
+ - "result_receive_rate_rate_unit",
+ - "result_pdr_lower_rate_value",
+ - "result_pdr_lower_rate_unit",
+ - "result_ndr_lower_rate_value",
+ - "result_ndr_lower_rate_unit",
+ - "tm_metric",
+ - "tm_value".
+
+ :param selection: User's selection (metrics and labels).
+ :param ignore_host: Ignore 'hostname' and 'hook' labels in metrics.
+ :type selection: dict
+ :type ignore_host: bool
+ :returns: Dataframe with selected data.
+ :rtype: pandas.DataFrame
+ """
+
+ if self._data is None:
+ return pd.DataFrame()
+ if self._data.empty:
+ return pd.DataFrame()
+ if not selection:
+ return pd.DataFrame()
+
+ df_sel = pd.DataFrame.from_dict(selection)
+ lst_rows = list()
+ for _, row in self._data.iterrows():
+ tm_row = row["telemetry"]
+ for _, tm_sel in df_sel.iterrows():
+ df_tmp = tm_row.loc[tm_row["metric"] == tm_sel["metric"]]
+ for _, tm in df_tmp.iterrows():
+ do_it = False
+ if ignore_host:
+ if tm["labels"][2:] == tm_sel["labels"][2:]:
+ labels = ','.join(
+ [f"{i[0]}='{i[1]}'" for i in tm["labels"][2:]]
+ )
+ do_it = True
+ else:
+ if tm["labels"] == tm_sel["labels"]:
+ labels = ','.join(
+ [f"{i[0]}='{i[1]}'" for i in tm["labels"]]
+ )
+ do_it = True
+ if do_it:
+ row["tm_metric"] = f"{tm['metric']}{{{labels}}}"
+ row["tm_value"] = tm["value"]
+ lst_rows.append(
+ row.drop(labels=["telemetry", ]).to_frame().T
+ )
+ if lst_rows:
+ return pd.concat(
+ lst_rows, ignore_index=True, axis=0, copy=False
+ ).drop_duplicates()
+ else:
+ return pd.DataFrame()
diff --git a/csit.infra.dash/app/cdash/utils/tooltips.yaml b/csit.infra.dash/app/cdash/utils/tooltips.yaml
new file mode 100644
index 0000000000..a51e9ffae4
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/tooltips.yaml
@@ -0,0 +1,51 @@
+help-area:
+ The area defines a DUT packet path and lookup type.
+help-cadence:
+ The cadence of the Jenkins job which runs the tests.
+help-cmp-parameter:
+ The parameter to be used for comparison.
+help-cmp-value:
+ The value of parameter to be used for comparison.
+help-cores:
+ Number of cores the DUT uses during the test.
+help-csit-dut:
+ The version of CSIT (the part in front of the first hyphen) and the version of
+ Device under Test (the rest).
+help-data-type:
+ The type of collected data.
+help-download:
+ Download the selected data as a csv file.
+help-dut:
+ Device Under Test (DUT) - In software networking, “device” denotes a specific
+ piece of software tasked with packet processing. Such device is surrounded
+ with other software components (such as operating system kernel).
+help-dut-ver:
+ The version of the Device under Test.
+help-framesize:
+ Frame size - size of an Ethernet Layer-2 frame on the wire, including any VLAN
+ tags (dot1q, dot1ad) and Ethernet FCS, but excluding Ethernet preamble and
+ inter-frame gap. Measured in Bytes.
+help-infra:
+ Infrastructure is defined by the toplology (number of nodes), processor
+ architecture, NIC and driver.
+help-measurement:
+ The measured quantity in interest.
+help-normalize:
+ Normalize the results to CPU frequency 2GHz. The results from AWS environment
+ are not normalized as we do not know the exact value of CPU frequency.
+help-release:
+ The CSIT release.
+help-show-latency:
+ If selected, the latency is included in tables.
+help-tbed:
+ The test bed is defined by toplology (number of nodes) and processor
+ architecture.
+help-test:
+ The test specification consists of packet encapsulation, VPP packet processing
+ (packet forwarding mode and packet processing function(s)) and packet
+ forwarding path.
+help-ttype:
+ Main measured variable.
+help-url:
+ URL with current configuration. If there is no "Copy URL" button, use triple
+ click.
diff --git a/csit.infra.dash/app/cdash/utils/trigger.py b/csit.infra.dash/app/cdash/utils/trigger.py
new file mode 100644
index 0000000000..da0768b070
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/trigger.py
@@ -0,0 +1,65 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""A module implementing the processing of a trigger.
+"""
+
+from typing import Any
+
+from json import loads, JSONDecodeError
+
+
+class Trigger:
+ """
+ """
+ def __init__(self, trigger) -> None:
+ """
+ """
+ self._id = trigger[0]["prop_id"].split(".")
+ self._param = self._id[1]
+ try:
+ self._id = loads(self._id[0])
+ except (JSONDecodeError, TypeError):
+ # It is a string
+ self._id = {"type": self._id[0], "index": None}
+ self._val = trigger[0]["value"]
+
+ def __str__(self) -> str:
+ return (
+ f"\nTrigger:\n"
+ f" ID: {self._id}\n"
+ f" Type: {self._id['type']}\n"
+ f" Index: {self._id['index']}\n"
+ f" Parameter: {self._param}\n"
+ f" Value: {self._val}\n"
+ )
+
+ @property
+ def id(self) -> dict:
+ return self._id
+
+ @property
+ def type(self) -> str:
+ return self._id["type"]
+
+ @property
+ def idx(self) -> Any:
+ return self._id["index"]
+
+ @property
+ def parameter(self) -> str:
+ return self._param
+
+ @property
+ def value(self) -> Any:
+ return self._val
diff --git a/csit.infra.dash/app/cdash/utils/url_processing.py b/csit.infra.dash/app/cdash/utils/url_processing.py
new file mode 100644
index 0000000000..c436ebc830
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/url_processing.py
@@ -0,0 +1,99 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""URL decoding and parsing and URL encoding.
+"""
+
+import logging
+
+from base64 import urlsafe_b64encode, urlsafe_b64decode
+from urllib.parse import urlencode, urlunparse, urlparse, parse_qs
+from zlib import compress, decompress
+from zlib import error as ZlibErr
+from binascii import Error as BinasciiErr
+
+
+def url_encode(params: dict) -> str:
+ """Encode the URL parameters and zip them and create the whole URL using
+ given data.
+
+ :param params: All data necessary to create the URL:
+ - scheme,
+ - network location,
+ - path,
+ - query,
+ - parameters.
+ :type params: dict
+ :returns: Encoded URL.
+ :rtype: str
+ """
+
+ url_params = params.get("params", None)
+ if url_params:
+ encoded_params = urlsafe_b64encode(
+ compress(urlencode(url_params).encode("utf-8"), level=9)
+ ).rstrip(b"=").decode("utf-8")
+ else:
+ encoded_params = str()
+
+ return urlunparse((
+ params.get("scheme", "http"),
+ params.get("netloc", str()),
+ params.get("path", str()),
+ str(), # params
+ params.get("query", str()),
+ encoded_params
+ ))
+
+
+def url_decode(url: str) -> dict:
+ """Parse the given URL and decode the parameters.
+
+ :param url: URL to be parsed and decoded.
+ :type url: str
+ :returns: Paresed URL.
+ :rtype: dict
+ """
+
+ try:
+ parsed_url = urlparse(url)
+ except ValueError as err:
+ logging.warning(f"\nThe url {url} is not valid, ignoring.\n{repr(err)}")
+ return dict()
+
+ if parsed_url.fragment:
+ try:
+ padding = b"=" * (4 - (len(parsed_url.fragment) % 4))
+ params = parse_qs(decompress(
+ urlsafe_b64decode(
+ (parsed_url.fragment.encode("utf-8") + padding)
+ )).decode("utf-8")
+ )
+ except (BinasciiErr, UnicodeDecodeError, ZlibErr) as err:
+ logging.warning(
+ f"\nNot possible to decode the parameters from url: {url}"
+ f"\nEncoded parameters: '{parsed_url.fragment}'"
+ f"\n{repr(err)}"
+ )
+ return dict()
+ else:
+ params = None
+
+ return {
+ "scheme": parsed_url.scheme,
+ "netloc": parsed_url.netloc,
+ "path": parsed_url.path,
+ "query": parsed_url.query,
+ "fragment": parsed_url.fragment,
+ "params": params
+ }
diff --git a/csit.infra.dash/app/cdash/utils/utils.py b/csit.infra.dash/app/cdash/utils/utils.py
new file mode 100644
index 0000000000..306b4f60d1
--- /dev/null
+++ b/csit.infra.dash/app/cdash/utils/utils.py
@@ -0,0 +1,895 @@
+# Copyright (c) 2024 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Functions used by Dash applications.
+"""
+
+import pandas as pd
+import plotly.graph_objects as go
+import dash_bootstrap_components as dbc
+
+import hdrh.histogram
+import hdrh.codec
+
+from math import sqrt
+from dash import dcc, no_update, html
+from datetime import datetime
+
+from ..utils.constants import Constants as C
+from ..utils.url_processing import url_encode
+from ..utils.trigger import Trigger
+
+
+def get_color(idx: int) -> str:
+ """Returns a color from the list defined in Constants.PLOT_COLORS defined by
+ its index.
+
+ :param idx: Index of the color.
+ :type idx: int
+ :returns: Color defined by hex code.
+ :trype: str
+ """
+ return C.PLOT_COLORS[idx % len(C.PLOT_COLORS)]
+
+
+def show_tooltip(tooltips:dict, id: str, title: str,
+ clipboard_id: str=None) -> list:
+ """Generate list of elements to display a text (e.g. a title) with a
+ tooltip and optionaly with Copy&Paste icon and the clipboard
+ functionality enabled.
+
+ :param tooltips: Dictionary with tooltips.
+ :param id: Tooltip ID.
+ :param title: A text for which the tooltip will be displayed.
+ :param clipboard_id: If defined, a Copy&Paste icon is displayed and the
+ clipboard functionality is enabled.
+ :type tooltips: dict
+ :type id: str
+ :type title: str
+ :type clipboard_id: str
+ :returns: List of elements to display a text with a tooltip and
+ optionaly with Copy&Paste icon.
+ :rtype: list
+ """
+
+ return [
+ dcc.Clipboard(target_id=clipboard_id, title="Copy URL") \
+ if clipboard_id else str(),
+ f"{title} ",
+ dbc.Badge(
+ id=id,
+ children="?",
+ pill=True,
+ color="white",
+ text_color="info",
+ class_name="border ms-1",
+ ),
+ dbc.Tooltip(
+ children=tooltips.get(id, str()),
+ target=id,
+ placement="auto"
+ )
+ ]
+
+
+def label(key: str) -> str:
+ """Returns a label for input elements (dropdowns, ...).
+
+ If the label is not defined, the function returns the provided key.
+
+ :param key: The key to the label defined in Constants.LABELS.
+ :type key: str
+ :returns: Label.
+ :rtype: str
+ """
+ return C.LABELS.get(key, key)
+
+
+def sync_checklists(options: list, sel: list, all: list, id: str) -> tuple:
+ """Synchronize a checklist with defined "options" with its "All" checklist.
+
+ :param options: List of options for the cheklist.
+ :param sel: List of selected options.
+ :param all: List of selected option from "All" checklist.
+ :param id: ID of a checklist to be used for synchronization.
+ :returns: Tuple of lists with otions for both checklists.
+ :rtype: tuple of lists
+ """
+ opts = {v["value"] for v in options}
+ if id =="all":
+ sel = list(opts) if all else list()
+ else:
+ all = ["all", ] if set(sel) == opts else list()
+ return sel, all
+
+
+def list_tests(selection: dict) -> list:
+ """Transform list of tests to a list of dictionaries usable by checkboxes.
+
+ :param selection: List of tests to be displayed in "Selected tests" window.
+ :type selection: list
+ :returns: List of dictionaries with "label", "value" pairs for a checkbox.
+ :rtype: list
+ """
+ if selection:
+ return [{"label": v["id"], "value": v["id"]} for v in selection]
+ else:
+ return list()
+
+
+def get_date(s_date: str) -> datetime:
+ """Transform string reprezentation of date to datetime.datetime data type.
+
+ :param s_date: String reprezentation of date.
+ :type s_date: str
+ :returns: Date as datetime.datetime.
+ :rtype: datetime.datetime
+ """
+ return datetime(int(s_date[0:4]), int(s_date[5:7]), int(s_date[8:10]))
+
+
+def gen_new_url(url_components: dict, params: dict) -> str:
+ """Generate a new URL with encoded parameters.
+
+ :param url_components: Dictionary with URL elements. It should contain
+ "scheme", "netloc" and "path".
+ :param url_components: URL parameters to be encoded to the URL.
+ :type parsed_url: dict
+ :type params: dict
+ :returns Encoded URL with parameters.
+ :rtype: str
+ """
+
+ if url_components:
+ return url_encode(
+ {
+ "scheme": url_components.get("scheme", ""),
+ "netloc": url_components.get("netloc", ""),
+ "path": url_components.get("path", ""),
+ "params": params
+ }
+ )
+ else:
+ return str()
+
+
+def get_duts(df: pd.DataFrame) -> list:
+ """Get the list of DUTs from the pre-processed information about jobs.
+
+ :param df: DataFrame with information about jobs.
+ :type df: pandas.DataFrame
+ :returns: Alphabeticaly sorted list of DUTs.
+ :rtype: list
+ """
+ return sorted(list(df["dut"].unique()))
+
+
+def get_ttypes(df: pd.DataFrame, dut: str) -> list:
+ """Get the list of test types from the pre-processed information about
+ jobs.
+
+ :param df: DataFrame with information about jobs.
+ :param dut: The DUT for which the list of test types will be populated.
+ :type df: pandas.DataFrame
+ :type dut: str
+ :returns: Alphabeticaly sorted list of test types.
+ :rtype: list
+ """
+ return sorted(list(df.loc[(df["dut"] == dut)]["ttype"].unique()))
+
+
+def get_cadences(df: pd.DataFrame, dut: str, ttype: str) -> list:
+ """Get the list of cadences from the pre-processed information about
+ jobs.
+
+ :param df: DataFrame with information about jobs.
+ :param dut: The DUT for which the list of cadences will be populated.
+ :param ttype: The test type for which the list of cadences will be
+ populated.
+ :type df: pandas.DataFrame
+ :type dut: str
+ :type ttype: str
+ :returns: Alphabeticaly sorted list of cadences.
+ :rtype: list
+ """
+ return sorted(list(df.loc[(
+ (df["dut"] == dut) &
+ (df["ttype"] == ttype)
+ )]["cadence"].unique()))
+
+
+def get_test_beds(df: pd.DataFrame, dut: str, ttype: str, cadence: str) -> list:
+ """Get the list of test beds from the pre-processed information about
+ jobs.
+
+ :param df: DataFrame with information about jobs.
+ :param dut: The DUT for which the list of test beds will be populated.
+ :param ttype: The test type for which the list of test beds will be
+ populated.
+ :param cadence: The cadence for which the list of test beds will be
+ populated.
+ :type df: pandas.DataFrame
+ :type dut: str
+ :type ttype: str
+ :type cadence: str
+ :returns: Alphabeticaly sorted list of test beds.
+ :rtype: list
+ """
+ return sorted(list(df.loc[(
+ (df["dut"] == dut) &
+ (df["ttype"] == ttype) &
+ (df["cadence"] == cadence)
+ )]["tbed"].unique()))
+
+
+def get_job(df: pd.DataFrame, dut, ttype, cadence, testbed):
+ """Get the name of a job defined by dut, ttype, cadence, test bed.
+ Input information comes from the control panel.
+
+ :param df: DataFrame with information about jobs.
+ :param dut: The DUT for which the job name will be created.
+ :param ttype: The test type for which the job name will be created.
+ :param cadence: The cadence for which the job name will be created.
+ :param testbed: The test bed for which the job name will be created.
+ :type df: pandas.DataFrame
+ :type dut: str
+ :type ttype: str
+ :type cadence: str
+ :type testbed: str
+ :returns: Job name.
+ :rtype: str
+ """
+ return df.loc[(
+ (df["dut"] == dut) &
+ (df["ttype"] == ttype) &
+ (df["cadence"] == cadence) &
+ (df["tbed"] == testbed)
+ )]["job"].item()
+
+
+def generate_options(opts: list, sort: bool=True) -> list:
+ """Return list of options for radio items in control panel. The items in
+ the list are dictionaries with keys "label" and "value".
+
+ :params opts: List of options (str) to be used for the generated list.
+ :type opts: list
+ :returns: List of options (dict).
+ :rtype: list
+ """
+ if sort:
+ opts = sorted(opts)
+ return [{"label": i, "value": i} for i in opts]
+
+
+def set_job_params(df: pd.DataFrame, job: str) -> dict:
+ """Create a dictionary with all options and values for (and from) the
+ given job.
+
+ :param df: DataFrame with information about jobs.
+ :params job: The name of job for and from which the dictionary will be
+ created.
+ :type df: pandas.DataFrame
+ :type job: str
+ :returns: Dictionary with all options and values for (and from) the
+ given job.
+ :rtype: dict
+ """
+
+ l_job = job.split("-")
+ return {
+ "job": job,
+ "dut": l_job[1],
+ "ttype": l_job[3],
+ "cadence": l_job[4],
+ "tbed": "-".join(l_job[-2:]),
+ "duts": generate_options(get_duts(df)),
+ "ttypes": generate_options(get_ttypes(df, l_job[1])),
+ "cadences": generate_options(get_cadences(df, l_job[1], l_job[3])),
+ "tbeds": generate_options(
+ get_test_beds(df, l_job[1], l_job[3], l_job[4]))
+ }
+
+
+def get_list_group_items(
+ items: list,
+ type: str,
+ colorize: bool=True,
+ add_index: bool=False
+ ) -> list:
+ """Generate list of ListGroupItems with checkboxes with selected items.
+
+ :param items: List of items to be displayed in the ListGroup.
+ :param type: The type part of an element ID.
+ :param colorize: If True, the color of labels is set, otherwise the default
+ color is used.
+ :param add_index: Add index to the list items.
+ :type items: list
+ :type type: str
+ :type colorize: bool
+ :type add_index: bool
+ :returns: List of ListGroupItems with checkboxes with selected items.
+ :rtype: list
+ """
+
+ children = list()
+ for i, l in enumerate(items):
+ idx = f"{i + 1}. " if add_index else str()
+ label = f"{idx}{l['id']}" if isinstance(l, dict) else f"{idx}{l}"
+ children.append(
+ dbc.ListGroupItem(
+ children=[
+ dbc.Checkbox(
+ id={"type": type, "index": i},
+ label=label,
+ value=False,
+ label_class_name="m-0 p-0",
+ label_style={
+ "font-size": ".875em",
+ "color": get_color(i) if colorize else "#55595c"
+ },
+ class_name="info"
+ )
+ ],
+ class_name="p-0"
+ )
+ )
+
+ return children
+
+
+def relative_change_stdev(mean1, mean2, std1, std2):
+ """Compute relative standard deviation of change of two values.
+
+ The "1" values are the base for comparison.
+ Results are returned as percentage (and percentual points for stdev).
+ Linearized theory is used, so results are wrong for relatively large stdev.
+
+ :param mean1: Mean of the first number.
+ :param mean2: Mean of the second number.
+ :param std1: Standard deviation estimate of the first number.
+ :param std2: Standard deviation estimate of the second number.
+ :type mean1: float
+ :type mean2: float
+ :type std1: float
+ :type std2: float
+ :returns: Relative change and its stdev.
+ :rtype: float
+ """
+ mean1, mean2 = float(mean1), float(mean2)
+ quotient = mean2 / mean1
+ first = std1 / mean1
+ second = std2 / mean2
+ std = quotient * sqrt(first * first + second * second)
+ return (quotient - 1) * 100, std * 100
+
+
+def get_hdrh_latencies(row: pd.Series, name: str) -> dict:
+ """Get the HDRH latencies from the test data.
+
+ :param row: A row fron the data frame with test data.
+ :param name: The test name to be displayed as the graph title.
+ :type row: pandas.Series
+ :type name: str
+ :returns: Dictionary with HDRH latencies.
+ :rtype: dict
+ """
+
+ latencies = {"name": name}
+ for key in C.LAT_HDRH:
+ try:
+ latencies[key] = row[key]
+ except KeyError:
+ return None
+
+ return latencies
+
+
+def graph_hdrh_latency(data: dict, layout: dict) -> go.Figure:
+ """Generate HDR Latency histogram graphs.
+
+ :param data: HDRH data.
+ :param layout: Layout of plot.ly graph.
+ :type data: dict
+ :type layout: dict
+ :returns: HDR latency Histogram.
+ :rtype: plotly.graph_objects.Figure
+ """
+
+ fig = None
+
+ traces = list()
+ for idx, (lat_name, lat_hdrh) in enumerate(data.items()):
+ try:
+ decoded = hdrh.histogram.HdrHistogram.decode(lat_hdrh)
+ except (hdrh.codec.HdrLengthException, TypeError):
+ continue
+ previous_x = 0.0
+ prev_perc = 0.0
+ xaxis = list()
+ yaxis = list()
+ hovertext = list()
+ for item in decoded.get_recorded_iterator():
+ # The real value is "percentile".
+ # For 100%, we cut that down to "x_perc" to avoid
+ # infinity.
+ percentile = item.percentile_level_iterated_to
+ x_perc = min(percentile, C.PERCENTILE_MAX)
+ xaxis.append(previous_x)
+ yaxis.append(item.value_iterated_to)
+ hovertext.append(
+ f"<b>{C.GRAPH_LAT_HDRH_DESC[lat_name]}</b><br>"
+ f"Direction: {('W-E', 'E-W')[idx % 2]}<br>"
+ f"Percentile: {prev_perc:.5f}-{percentile:.5f}%<br>"
+ f"Latency: {item.value_iterated_to}uSec"
+ )
+ next_x = 100.0 / (100.0 - x_perc)
+ xaxis.append(next_x)
+ yaxis.append(item.value_iterated_to)
+ hovertext.append(
+ f"<b>{C.GRAPH_LAT_HDRH_DESC[lat_name]}</b><br>"
+ f"Direction: {('W-E', 'E-W')[idx % 2]}<br>"
+ f"Percentile: {prev_perc:.5f}-{percentile:.5f}%<br>"
+ f"Latency: {item.value_iterated_to}uSec"
+ )
+ previous_x = next_x
+ prev_perc = percentile
+
+ traces.append(
+ go.Scatter(
+ x=xaxis,
+ y=yaxis,
+ name=C.GRAPH_LAT_HDRH_DESC[lat_name],
+ mode="lines",
+ legendgroup=C.GRAPH_LAT_HDRH_DESC[lat_name],
+ showlegend=bool(idx % 2),
+ line=dict(
+ color=get_color(int(idx/2)),
+ dash="solid",
+ width=1 if idx % 2 else 2
+ ),
+ hovertext=hovertext,
+ hoverinfo="text"
+ )
+ )
+ if traces:
+ fig = go.Figure()
+ fig.add_traces(traces)
+ layout_hdrh = layout.get("plot-hdrh-latency", None)
+ if lat_hdrh:
+ fig.update_layout(layout_hdrh)
+
+ return fig
+
+
+def navbar_trending(active: tuple):
+ """Add nav element with navigation panel. It is placed on the top.
+
+ :param active: Tuple of boolean values defining the active items in the
+ navbar. True == active
+ :type active: tuple
+ :returns: Navigation bar.
+ :rtype: dbc.NavbarSimple
+ """
+ return dbc.NavbarSimple(
+ children=[
+ dbc.NavItem(dbc.NavLink(
+ C.TREND_TITLE,
+ active=active[0],
+ external_link=True,
+ href="/trending"
+ )),
+ dbc.NavItem(dbc.NavLink(
+ C.NEWS_TITLE,
+ active=active[1],
+ external_link=True,
+ href="/news"
+ )),
+ dbc.NavItem(dbc.NavLink(
+ C.STATS_TITLE,
+ active=active[2],
+ external_link=True,
+ href="/stats"
+ )),
+ dbc.NavItem(dbc.NavLink(
+ C.SEARCH_TITLE,
+ active=active[3],
+ external_link=True,
+ href="/search"
+ )),
+ dbc.NavItem(dbc.NavLink(
+ "Documentation",
+ id="btn-documentation",
+ ))
+ ],
+ id="navbarsimple-main",
+ brand=C.BRAND,
+ brand_href="/",
+ brand_external_link=True,
+ class_name="p-2",
+ fluid=True
+ )
+
+
+def navbar_report(active: tuple):
+ """Add nav element with navigation panel. It is placed on the top.
+
+ :param active: Tuple of boolean values defining the active items in the
+ navbar. True == active
+ :type active: tuple
+ :returns: Navigation bar.
+ :rtype: dbc.NavbarSimple
+ """
+ return dbc.NavbarSimple(
+ id="navbarsimple-main",
+ children=[
+ dbc.NavItem(dbc.NavLink(
+ C.REPORT_TITLE,
+ active=active[0],
+ external_link=True,
+ href="/report"
+ )),
+ dbc.NavItem(dbc.NavLink(
+ "Comparisons",
+ active=active[1],
+ external_link=True,
+ href="/comparisons"
+ )),
+ dbc.NavItem(dbc.NavLink(
+ "Coverage Data",
+ active=active[2],
+ external_link=True,
+ href="/coverage"
+ )),
+ dbc.NavItem(dbc.NavLink(
+ C.SEARCH_TITLE,
+ active=active[3],
+ external_link=True,
+ href="/search"
+ )),
+ dbc.NavItem(dbc.NavLink(
+ "Documentation",
+ id="btn-documentation",
+ ))
+ ],
+ brand=C.BRAND,
+ brand_href="/",
+ brand_external_link=True,
+ class_name="p-2",
+ fluid=True
+ )
+
+
+def filter_table_data(
+ store_table_data: list,
+ table_filter: str
+ ) -> list:
+ """Filter table data using user specified filter.
+
+ :param store_table_data: Table data represented as a list of records.
+ :param table_filter: User specified filter.
+ :type store_table_data: list
+ :type table_filter: str
+ :returns: A new table created by filtering of table data represented as
+ a list of records.
+ :rtype: list
+ """
+
+ # Checks:
+ if not any((table_filter, store_table_data, )):
+ return store_table_data
+
+ def _split_filter_part(filter_part: str) -> tuple:
+ """Split a part of filter into column name, operator and value.
+ A "part of filter" is a sting berween "&&" operator.
+
+ :param filter_part: A part of filter.
+ :type filter_part: str
+ :returns: Column name, operator, value
+ :rtype: tuple[str, str, str|float]
+ """
+ for operator_type in C.OPERATORS:
+ for operator in operator_type:
+ if operator in filter_part:
+ name_p, val_p = filter_part.split(operator, 1)
+ name = name_p[name_p.find("{") + 1 : name_p.rfind("}")]
+ val_p = val_p.strip()
+ if (val_p[0] == val_p[-1] and val_p[0] in ("'", '"', '`')):
+ value = val_p[1:-1].replace("\\" + val_p[0], val_p[0])
+ else:
+ try:
+ value = float(val_p)
+ except ValueError:
+ value = val_p
+
+ return name, operator_type[0].strip(), value
+ return (None, None, None)
+
+ df = pd.DataFrame.from_records(store_table_data)
+ for filter_part in table_filter.split(" && "):
+ col_name, operator, filter_value = _split_filter_part(filter_part)
+ if operator == "contains":
+ df = df.loc[df[col_name].str.contains(filter_value, regex=True)]
+ elif operator in ("eq", "ne", "lt", "le", "gt", "ge"):
+ # These operators match pandas series operator method names.
+ df = df.loc[getattr(df[col_name], operator)(filter_value)]
+ elif operator == "datestartswith":
+ # This is a simplification of the front-end filtering logic,
+ # only works with complete fields in standard format.
+ # Currently not used in comparison tables.
+ df = df.loc[df[col_name].str.startswith(filter_value)]
+
+ return df.to_dict("records")
+
+
+def sort_table_data(
+ store_table_data: list,
+ sort_by: list
+ ) -> list:
+ """Sort table data using user specified order.
+
+ :param store_table_data: Table data represented as a list of records.
+ :param sort_by: User specified sorting order (multicolumn).
+ :type store_table_data: list
+ :type sort_by: list
+ :returns: A new table created by sorting the table data represented as
+ a list of records.
+ :rtype: list
+ """
+
+ # Checks:
+ if not any((sort_by, store_table_data, )):
+ return store_table_data
+
+ df = pd.DataFrame.from_records(store_table_data)
+ if len(sort_by):
+ dff = df.sort_values(
+ [col["column_id"] for col in sort_by],
+ ascending=[col["direction"] == "asc" for col in sort_by],
+ inplace=False
+ )
+ else:
+ # No sort is applied
+ dff = df
+
+ return dff.to_dict("records")
+
+
+def show_trending_graph_data(
+ trigger: Trigger,
+ data: dict,
+ graph_layout: dict
+ ) -> tuple:
+ """Generates the data for the offcanvas displayed when a particular point in
+ a trending graph (daily data) is clicked on.
+
+ :param trigger: The information from trigger when the data point is clicked
+ on.
+ :param graph: The data from the clicked point in the graph.
+ :param graph_layout: The layout of the HDRH latency graph.
+ :type trigger: Trigger
+ :type graph: dict
+ :type graph_layout: dict
+ :returns: The data to be displayed on the offcanvas and the information to
+ show the offcanvas.
+ :rtype: tuple(list, list, bool)
+ """
+
+ if trigger.idx == "tput":
+ idx = 0
+ elif trigger.idx == "bandwidth":
+ idx = 1
+ elif trigger.idx == "lat":
+ idx = len(data) - 1
+ else:
+ return list(), list(), False
+ try:
+ data = data[idx]["points"][0]
+ except (IndexError, KeyError, ValueError, TypeError):
+ return list(), list(), False
+
+ metadata = no_update
+ graph = list()
+
+ list_group_items = list()
+ for itm in data.get("text", None).split("<br>"):
+ if not itm:
+ continue
+ lst_itm = itm.split(": ")
+ if lst_itm[0] == "csit-ref":
+ list_group_item = dbc.ListGroupItem([
+ dbc.Badge(lst_itm[0]),
+ html.A(
+ lst_itm[1],
+ href=f"{C.URL_LOGS}{lst_itm[1]}",
+ target="_blank"
+ )
+ ])
+ else:
+ list_group_item = dbc.ListGroupItem([
+ dbc.Badge(lst_itm[0]),
+ lst_itm[1]
+ ])
+ list_group_items.append(list_group_item)
+
+ if trigger.idx == "tput":
+ title = "Throughput"
+ elif trigger.idx == "bandwidth":
+ title = "Bandwidth"
+ elif trigger.idx == "lat":
+ title = "Latency"
+ hdrh_data = data.get("customdata", None)
+ if hdrh_data:
+ graph = [dbc.Card(
+ class_name="gy-2 p-0",
+ children=[
+ dbc.CardHeader(hdrh_data.pop("name")),
+ dbc.CardBody(
+ dcc.Graph(
+ id="hdrh-latency-graph",
+ figure=graph_hdrh_latency(hdrh_data, graph_layout)
+ )
+ )
+ ])
+ ]
+
+ metadata = [
+ dbc.Card(
+ class_name="gy-2 p-0",
+ children=[
+ dbc.CardHeader(children=[
+ dcc.Clipboard(
+ target_id="tput-lat-metadata",
+ title="Copy",
+ style={"display": "inline-block"}
+ ),
+ title
+ ]),
+ dbc.CardBody(
+ dbc.ListGroup(list_group_items, flush=True),
+ id="tput-lat-metadata",
+ class_name="p-0",
+ )
+ ]
+ )
+ ]
+
+ return metadata, graph, True
+
+
+def show_iterative_graph_data(
+ trigger: Trigger,
+ data: dict,
+ graph_layout: dict
+ ) -> tuple:
+ """Generates the data for the offcanvas displayed when a particular point in
+ a box graph (iterative data) is clicked on.
+
+ :param trigger: The information from trigger when the data point is clicked
+ on.
+ :param graph: The data from the clicked point in the graph.
+ :param graph_layout: The layout of the HDRH latency graph.
+ :type trigger: Trigger
+ :type graph: dict
+ :type graph_layout: dict
+ :returns: The data to be displayed on the offcanvas and the information to
+ show the offcanvas.
+ :rtype: tuple(list, list, bool)
+ """
+
+ if trigger.idx == "tput":
+ idx = 0
+ elif trigger.idx == "bandwidth":
+ idx = 1
+ elif trigger.idx == "lat":
+ idx = len(data) - 1
+ else:
+ return list(), list(), False
+
+ try:
+ data = data[idx]["points"]
+ except (IndexError, KeyError, ValueError, TypeError):
+ return list(), list(), False
+
+ def _process_stats(data: list, param: str) -> list:
+ """Process statistical data provided by plot.ly box graph.
+
+ :param data: Statistical data provided by plot.ly box graph.
+ :param param: Parameter saying if the data come from "tput" or
+ "lat" graph.
+ :type data: list
+ :type param: str
+ :returns: Listo of tuples where the first value is the
+ statistic's name and the secont one it's value.
+ :rtype: list
+ """
+ if len(data) == 7:
+ stats = ("max", "upper fence", "q3", "median", "q1",
+ "lower fence", "min")
+ elif len(data) == 9:
+ stats = ("outlier", "max", "upper fence", "q3", "median",
+ "q1", "lower fence", "min", "outlier")
+ elif len(data) == 1:
+ if param == "lat":
+ stats = ("average latency at 50% PDR", )
+ elif param == "bandwidth":
+ stats = ("bandwidth", )
+ else:
+ stats = ("throughput", )
+ else:
+ return list()
+ unit = " [us]" if param == "lat" else str()
+ return [(f"{stat}{unit}", f"{value['y']:,.0f}")
+ for stat, value in zip(stats, data)]
+
+ customdata = data[0].get("customdata", dict())
+ datapoint = customdata.get("metadata", dict())
+ hdrh_data = customdata.get("hdrh", dict())
+
+ list_group_items = list()
+ for k, v in datapoint.items():
+ if k == "csit-ref":
+ if len(data) > 1:
+ continue
+ list_group_item = dbc.ListGroupItem([
+ dbc.Badge(k),
+ html.A(v, href=f"{C.URL_LOGS}{v}", target="_blank")
+ ])
+ else:
+ list_group_item = dbc.ListGroupItem([dbc.Badge(k), v])
+ list_group_items.append(list_group_item)
+
+ graph = list()
+ if trigger.idx == "tput":
+ title = "Throughput"
+ elif trigger.idx == "bandwidth":
+ title = "Bandwidth"
+ elif trigger.idx == "lat":
+ title = "Latency"
+ if len(data) == 1:
+ if hdrh_data:
+ graph = [dbc.Card(
+ class_name="gy-2 p-0",
+ children=[
+ dbc.CardHeader(hdrh_data.pop("name")),
+ dbc.CardBody(dcc.Graph(
+ id="hdrh-latency-graph",
+ figure=graph_hdrh_latency(hdrh_data, graph_layout)
+ ))
+ ])
+ ]
+
+ for k, v in _process_stats(data, trigger.idx):
+ list_group_items.append(dbc.ListGroupItem([dbc.Badge(k), v]))
+
+ metadata = [
+ dbc.Card(
+ class_name="gy-2 p-0",
+ children=[
+ dbc.CardHeader(children=[
+ dcc.Clipboard(
+ target_id="tput-lat-metadata",
+ title="Copy",
+ style={"display": "inline-block"}
+ ),
+ title
+ ]),
+ dbc.CardBody(
+ dbc.ListGroup(list_group_items, flush=True),
+ id="tput-lat-metadata",
+ class_name="p-0"
+ )
+ ]
+ )
+ ]
+
+ return metadata, graph, True