From 4d9d1ecc2cb3db86ae40c5625380bc4c483bcd98 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 3 Sep 2026 11:55:02 +0200 Subject: [PATCH 1/8] ui/status: split sensor data and jobs over two lazily-loaded tabs Context: - Issue #2446: the status page stacks the sensor connectivity table and the jobs table on one page, so every visit pays for both, even though a visitor is usually after one of them. Change: - Put each table in its own tab, and build a table only when its tab is first shown, so the page queries only what it displays. - Open the jobs tab by default, and remember the tab the user last opened in their session, as we do for the preferred graph legend position. - Add POST /api/v3_0/assets/status_page_tab to record that preference. Co-Authored-By: Claude Opus 5 --- flexmeasures/api/v3_0/__init__.py | 2 + flexmeasures/api/v3_0/assets.py | 66 ++++++ flexmeasures/ui/static/openapi-specs.json | 78 +++++++ flexmeasures/ui/templates/sensors/status.html | 191 +++++++++++------- 4 files changed, 266 insertions(+), 71 deletions(-) diff --git a/flexmeasures/api/v3_0/__init__.py b/flexmeasures/api/v3_0/__init__.py index d6b73a9cf0..75dff38a25 100644 --- a/flexmeasures/api/v3_0/__init__.py +++ b/flexmeasures/api/v3_0/__init__.py @@ -39,6 +39,7 @@ flex_context_schema_openAPI, AssetAPIQuerySchema, DefaultAssetViewJSONSchema, + StatusPageTabJSONSchema, ) from flexmeasures.data.schemas.annotations import AnnotationSchema from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema @@ -224,6 +225,7 @@ def create_openapi_specs(app: Flask): ("AnnotationSchema", AnnotationSchema), ("CopyAssetSchema", CopyAssetSchema), ("DefaultAssetViewJSONSchema", DefaultAssetViewJSONSchema), + ("StatusPageTabJSONSchema", StatusPageTabJSONSchema), ("AccountSchema", AccountSchema(partial=True)), ("AccountCreateSchema", AccountCreateSchema()), ("AccountPatchSchema", AccountPatchSchema()), diff --git a/flexmeasures/api/v3_0/assets.py b/flexmeasures/api/v3_0/assets.py index 33fe92af26..0e4d308d34 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -322,6 +322,17 @@ class DefaultAssetViewJSONSchema(Schema): ) +class StatusPageTabJSONSchema(Schema): + status_page_tab = fields.Str( + required=True, + validate=validate.OneOf(["jobs", "sensors"]), + metadata={ + "enum": ["jobs", "sensors"], + "description": "The tab to open on the asset's status page.", + }, + ) + + class KPIKwargsSchema(Schema): event_starts_after = AwareDateTimeField(format="iso", required=False) event_ends_before = AwareDateTimeField(format="iso", required=False) @@ -1723,6 +1734,61 @@ def update_default_asset_view(self, **kwargs): "message": "Default asset view updated successfully.", }, 200 + @route("/status_page_tab", methods=["POST"]) + @as_json + @use_kwargs(StatusPageTabJSONSchema, location="json") + def update_status_page_tab(self, **kwargs): + """ + .. :quickref: Assets; Remember which tab of the asset status page the current user last opened. + --- + post: + summary: Remember which tab of the asset status page the current user last opened. + description: | + The status page shows a sensor data tab and a jobs tab, of which only the opened one loads its data. + This endpoint records the user's choice in their session, so their next visit to a status page opens the same tab. + Without a recorded choice, the jobs tab opens. + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: StatusPageTabJSONSchema + examples: + status_page_tab: + summary: Opening the sensor data tab from now on + value: + status_page_tab: "sensors" + responses: + 200: + description: PROCESSED + content: + application/json: + examples: + message: + summary: Message + value: + message: "Preferred status page tab updated successfully." + 400: + description: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS + 401: + description: UNAUTHORIZED + 422: + description: UNPROCESSABLE_ENTITY + tags: + - Assets + """ + # Update the request.values, as that is where set_session_variables reads from. + request_values = request.values.copy() + request_values.update(kwargs) + request.values = request_values + + set_session_variables("status_page_tab") + + return { + "message": "Preferred status page tab updated successfully.", + }, 200 + @route("/keep_legends_below_graphs", methods=["POST"]) @as_json @use_kwargs( diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 255b097cb7..1286edf2dc 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4790,6 +4790,67 @@ ] } }, + "/api/v3_0/assets/status_page_tab": { + "post": { + "summary": "Remember which tab of the asset status page the current user last opened.", + "description": "The status page shows a sensor data tab and a jobs tab, of which only the opened one loads its data.\nThis endpoint records the user's choice in their session, so their next visit to a status page opens the same tab.\nWithout a recorded choice, the jobs tab opens.\n", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusPageTabJSONSchema" + }, + "examples": { + "status_page_tab": { + "summary": "Opening the sensor data tab from now on", + "value": { + "status_page_tab": "sensors" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "PROCESSED", + "content": { + "application/json": { + "examples": { + "message": { + "summary": "Message", + "value": { + "message": "Preferred status page tab updated successfully." + } + } + } + } + } + }, + "400": { + "description": "INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS" + }, + "401": { + "description": "UNAUTHORIZED" + }, + "422": { + "description": "UNPROCESSABLE_ENTITY" + }, + "429": { + "description": "TOO_MANY_REQUESTS - You called the API more often than your rate limit allows. Wait for as long as the Retry-After header says, then try again." + } + }, + "tags": [ + "Assets" + ] + } + }, "/api/v3_0/assets/types": { "get": { "summary": "Get list of available asset types", @@ -6266,6 +6327,23 @@ ], "additionalProperties": false }, + "StatusPageTabJSONSchema": { + "type": "object", + "properties": { + "status_page_tab": { + "type": "string", + "enum": [ + "jobs", + "sensors" + ], + "description": "The tab to open on the asset's status page." + } + }, + "required": [ + "status_page_tab" + ], + "additionalProperties": false + }, "AccountRole": { "type": "object", "properties": { diff --git a/flexmeasures/ui/templates/sensors/status.html b/flexmeasures/ui/templates/sensors/status.html index 9bdd3bc9c2..1037f6a925 100644 --- a/flexmeasures/ui/templates/sensors/status.html +++ b/flexmeasures/ui/templates/sensors/status.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% set active_page = "assets" %} +{% set status_page_tab = session.get("status_page_tab", "jobs") %} {% block title %} {{ asset.name }} - Status {% endblock %} @@ -12,41 +13,58 @@
- -

- Data connectivity for sensors of {{ asset.name }} - + + + + +
+ + +
+

+ Latest jobs of {{ asset.name }} + + + + + + + +

+ +
+ +
+ +
+
+ + +
+

+ Data connectivity for sensors of {{ asset.name }} + -

+ "> +

-
-
-
+
+
+
+
- -

- Latest jobs of {{ asset.name }} - - - - - - - -

- -
-
- -
@@ -118,10 +136,11 @@ }; } - let sensorTable; + // Both tables are built on first sight of their tab, so opening the page only queries what it shows. + let sensorTable, jobsTable; - $(document).ready(function () { - // SENSOR STATUS TABLE + function initSensorStatusTable() { + if (sensorTable) return; sensorTable = $("#sensorStatusTable").DataTable({ searching: false, paging: false, @@ -176,6 +195,77 @@ ); }, }); + } + + function initJobsTable() { + if (jobsTable) return; + jobsTable = $("#jobsTable").DataTable({ + order: [[0, "desc"]], + searching: false, + paging: false, + info: false, + columns: [ + { data: "created_at_timestamp", title: "Created At Timestamp", visible: false }, + { data: "created_at", title: "Created At", orderable: true, orderData: 0 }, + { data: "queue", title: "Queue", orderable: true}, + { data: "entity", title: "Entity", orderable: false}, + { data: "created_via", title: "Created Via", orderable: false}, + { data: "status", title: "Status", className: "text-right", orderable: false}, + { data: "info", title: "Info", className: "text-right", orderable: false}, + { data: "url", title: "URL", className: "d-none", orderable: false}, + ], + ajax: function (data, callback) { + $.ajax({ + url: `/api/v3_0/assets/${assetId}/jobs`, + method: "GET", + success: function (res) { + if (res.redis_connection_err) { + $("#redis_connection_err").removeClass("d-none").text(res.redis_connection_err); + } else { + $("#redis_connection_err").addClass("d-none").text(""); + } + const jobs = res.jobs.length ? res.jobs.map(JobRow) : []; + callback({ data: jobs }); + + const lastCallTime = Date.now(); + $("#jobs_time_ago").html(`(${getTimeAgo(lastCallTime)})`); + $(".jobs-time-ago").data("timestamp", lastCallTime); + }, + error: function (xhr) { + console.error("Error fetching jobs:", xhr); + callback({ data: [] }); + }, + }); + }, + }); + } + + function initTable(tabName) { + if (tabName === "sensors") { + initSensorStatusTable(); + } else { + initJobsTable(); + } + } + + function rememberStatusPageTab(tabName) { + fetch(`${window.location.origin}/api/v3_0/assets/status_page_tab`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status_page_tab: tabName }), + }).catch((error) => { + console.error("Error remembering the preferred status page tab:", error); + }); + } + + $(document).ready(function () { + initTable("{{ status_page_tab }}"); + + $("#statusTabs a[data-bs-toggle='tab']").on("shown.bs.tab", function () { + const tabName = $(this).data("status-page-tab"); + initTable(tabName); + rememberStatusPageTab(tabName); + }); $(document).on("click", ".sensor_refresh", function () { const sensorId = $(this).attr("id").split("_")[2]; @@ -228,47 +318,6 @@ }); }); - // JOBS TABLE - const jobsTable = $("#jobsTable").DataTable({ - order: [[0, "desc"]], - searching: false, - paging: false, - info: false, - columns: [ - { data: "created_at_timestamp", title: "Created At Timestamp", visible: false }, - { data: "created_at", title: "Created At", orderable: true, orderData: 0 }, - { data: "queue", title: "Queue", orderable: true}, - { data: "entity", title: "Entity", orderable: false}, - { data: "created_via", title: "Created Via", orderable: false}, - { data: "status", title: "Status", className: "text-right", orderable: false}, - { data: "info", title: "Info", className: "text-right", orderable: false}, - { data: "url", title: "URL", className: "d-none", orderable: false}, - ], - ajax: function (data, callback) { - $.ajax({ - url: `/api/v3_0/assets/${assetId}/jobs`, - method: "GET", - success: function (res) { - if (res.redis_connection_err) { - $("#redis_connection_err").removeClass("d-none").text(res.redis_connection_err); - } else { - $("#redis_connection_err").addClass("d-none").text(""); - } - const jobs = res.jobs.length ? res.jobs.map(JobRow) : []; - callback({ data: jobs }); - - const lastCallTime = Date.now(); - $("#jobs_time_ago").html(`(${getTimeAgo(lastCallTime)})`); - $(".jobs-time-ago").data("timestamp", lastCallTime); - }, - error: function (xhr) { - console.error("Error fetching jobs:", xhr); - callback({ data: [] }); - }, - }); - }, - }); - $.fn.dataTable.ext.errMode = 'none'; function updateTimeAgoDisplays() { @@ -286,7 +335,7 @@ }); } - $("#refresh_jobs").click(() => jobsTable.ajax.reload()); + $("#refresh_jobs").click(() => jobsTable && jobsTable.ajax.reload()); setInterval(updateTimeAgoDisplays, 10000); }); From 906266ab8e0d0b8181707cf62125c86cd4ebe962 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 3 Sep 2026 11:55:12 +0200 Subject: [PATCH 2/8] tests: cover the status page tabs and the tab preference endpoint Context: - Issue #2446 splits the status page into a jobs tab and a sensor data tab, of which only the opened one loads. Change: - Assert that the status page opens the jobs tab by default, and the sensor data tab once the session records that preference. - Assert that the new endpoint stores an accepted tab in the session, and rejects a tab the status page does not have. Co-Authored-By: Claude Opus 5 --- .../api/v3_0/tests/test_assets_api.py | 31 +++++++++++++++++++ flexmeasures/ui/tests/test_asset_crud.py | 28 +++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/flexmeasures/api/v3_0/tests/test_assets_api.py b/flexmeasures/api/v3_0/tests/test_assets_api.py index 4bd78a2cc3..b046927b00 100644 --- a/flexmeasures/api/v3_0/tests/test_assets_api.py +++ b/flexmeasures/api/v3_0/tests/test_assets_api.py @@ -2070,3 +2070,34 @@ def local_day(day: int) -> str: assert sum(totals.values()) == pytest.approx( 222.0 ), "each event counts once across neighbouring days, not twice" + + +@pytest.mark.parametrize( + "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True +) +@pytest.mark.parametrize("tab", ["jobs", "sensors"]) +def test_update_status_page_tab(client, setup_api_test_data, requesting_user, tab): + """Posting a status page tab records it in the session, for the next status page the user opens.""" + response = client.post( + url_for("AssetAPI:update_status_page_tab"), + json={"status_page_tab": tab}, + ) + assert response.status_code == 200 + with client.session_transaction() as session: + assert session["status_page_tab"] == tab + + +@pytest.mark.parametrize( + "requesting_user", ["test_prosumer_user@seita.nl"], indirect=True +) +def test_update_status_page_tab_rejects_unknown_tab( + client, setup_api_test_data, requesting_user +): + """Only the two tabs the status page actually has are accepted.""" + response = client.post( + url_for("AssetAPI:update_status_page_tab"), + json={"status_page_tab": "automations"}, + ) + assert response.status_code == 422 + with client.session_transaction() as session: + assert "status_page_tab" not in session diff --git a/flexmeasures/ui/tests/test_asset_crud.py b/flexmeasures/ui/tests/test_asset_crud.py index edc48a4d7a..0c00f3ca47 100644 --- a/flexmeasures/ui/tests/test_asset_crud.py +++ b/flexmeasures/ui/tests/test_asset_crud.py @@ -652,3 +652,31 @@ def test_group_field_hints_on_properties_page( assert lone_page.status_code == 200 assert b"Consider setting" not in lone_page.data assert b"Child assets can" not in lone_page.data + + +def test_asset_status_page_tabs(db, client, setup_assets, as_prosumer_user1): + """The status page splits sensor data from jobs, and opens the tab the user last looked at.""" + user = find_user_by_email("test_prosumer_user@seita.nl") + asset = user.account.generic_assets[0] + db.session.expunge(user) + + status_page = client.get( + url_for("AssetCrudUI:status", id=asset.id), follow_redirects=True + ) + assert status_page.status_code == 200 + assert b"Latest jobs of" in status_page.data + assert b"Data connectivity for sensors of" in status_page.data + # Without a recorded preference, the jobs tab opens, so only the jobs table loads. + assert b' Date: Thu, 3 Sep 2026 11:55:20 +0200 Subject: [PATCH 3/8] docs: add changelog entry for the status page tabs Co-Authored-By: Claude Opus 5 --- documentation/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 3940b4edac..b6dd291bac 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -17,6 +17,7 @@ v1.1.0 | September XX, 2026 New features ------------- +* The asset's status page now splits its sensor data and its jobs over two tabs, of which only the opened one loads its data, and it opens the tab you last looked at [see `PR #2466 `_] * Changing the selected time range on an asset or sensor chart now only loads the data that is actually new, instead of reloading the whole range, which makes stepping through or extending a long period much faster; reloading the page, or leaving it open for five minutes, still fetches everything afresh [see `PR #2433 `_] Infrastructure / Support From 248b33f2bc1ccac9cd0835cedb5c48e83faf4d95 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 3 Sep 2026 12:18:06 +0200 Subject: [PATCH 4/8] ui/status: stop the tab tables opting into the global pagination helper Context: - With both tables built up front, the "paginate" class was harmless: the global initializer in flexmeasures.js found them already built and just handed back their API. - Building a table only when its tab opens changed that. At page load the hidden table does not exist yet, so the global initializer created it with default options and no data source. Opening its tab could then no longer initialize it, DataTables refuses to reinitialize, and the swallowed error left an empty table behind until the page was reloaded. Change: - Drop the "paginate" class from both tables, as this page initializes them itself. Co-Authored-By: Claude Opus 5 --- flexmeasures/ui/templates/sensors/status.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/flexmeasures/ui/templates/sensors/status.html b/flexmeasures/ui/templates/sensors/status.html index 1037f6a925..5ae194cc71 100644 --- a/flexmeasures/ui/templates/sensors/status.html +++ b/flexmeasures/ui/templates/sensors/status.html @@ -38,7 +38,8 @@

- + +
@@ -60,7 +61,7 @@

-
+
From 9174979d904fe6c130e22e878e38e76a41594b4e Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 3 Sep 2026 12:18:07 +0200 Subject: [PATCH 5/8] tests: assert the status tables stay out of the global pagination helper Change: - Check that neither status table carries the "paginate" class, which would let flexmeasures.js build the table sitting in the tab that is not open. Co-Authored-By: Claude Opus 5 --- flexmeasures/ui/tests/test_asset_crud.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/flexmeasures/ui/tests/test_asset_crud.py b/flexmeasures/ui/tests/test_asset_crud.py index 0c00f3ca47..e70a060746 100644 --- a/flexmeasures/ui/tests/test_asset_crud.py +++ b/flexmeasures/ui/tests/test_asset_crud.py @@ -680,3 +680,26 @@ def test_asset_status_page_tabs(db, client, setup_assets, as_prosumer_user1): assert b'
]*>' % table_id, status_page.data + ).group() + assert b"paginate" not in table_tag, table_tag From 5c0e6ec372d7a46c8757f65ca0bf66c41e3c8774 Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 3 Sep 2026 12:26:44 +0200 Subject: [PATCH 6/8] ui/status: keep the jobs table out of the nav-on-click helper too Context: - Dropping the "paginate" class fixed only one of the two ways flexmeasures.js builds a table on page load. clickableTable(), which it applies to every "nav-on-click" table, initializes the table as its first step. - So the jobs table was still built empty whenever the page opened on the sensor data tab, and opening the jobs tab then found it already built and left it empty until a reload. Change: - Drop the "nav-on-click" class as well, and apply clickableTable() from the jobs table's own initialization instead. Its row handler is delegated, so it keeps working for the rows that arrive later. Co-Authored-By: Claude Opus 5 --- flexmeasures/ui/templates/sensors/status.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flexmeasures/ui/templates/sensors/status.html b/flexmeasures/ui/templates/sensors/status.html index 5ae194cc71..de97f3890a 100644 --- a/flexmeasures/ui/templates/sensors/status.html +++ b/flexmeasures/ui/templates/sensors/status.html @@ -38,8 +38,8 @@

- - + +
@@ -239,6 +239,8 @@ }); }, }); + // Make the rows navigable now that the table exists, as flexmeasures.js only does so on page load. + clickableTable(document.getElementById("jobsTable"), "URL"); } function initTable(tabName) { From 0b87b3b7156effb33ac7b13d44a27643aa98c28b Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 3 Sep 2026 12:26:45 +0200 Subject: [PATCH 7/8] tests: check both tab landings leave the status tables unbuilt Change: - Cover the "nav-on-click" class next to "paginate", for each of the two tabs the page can open on, and check the jobs rows stay navigable. Co-Authored-By: Claude Opus 5 --- flexmeasures/ui/tests/test_asset_crud.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/flexmeasures/ui/tests/test_asset_crud.py b/flexmeasures/ui/tests/test_asset_crud.py index e70a060746..3344be51e7 100644 --- a/flexmeasures/ui/tests/test_asset_crud.py +++ b/flexmeasures/ui/tests/test_asset_crud.py @@ -682,18 +682,22 @@ def test_asset_status_page_tabs(db, client, setup_assets, as_prosumer_user1): assert b'initTable("sensors")' in status_page.data -def test_status_page_tables_are_not_auto_paginated( - db, client, setup_assets, as_prosumer_user1 +@pytest.mark.parametrize("remembered_tab", ["jobs", "sensors"]) +def test_status_page_tables_are_not_built_on_page_load( + db, client, setup_assets, as_prosumer_user1, remembered_tab ): - """Neither status table opts into the global DataTables helper that flexmeasures.js applies to the 'paginate' class. + """Neither status table opts into a class by which flexmeasures.js builds a DataTable on page load. - That helper would build whichever table sits in the tab that is not open, using default options and no data source, - and the page could then no longer initialise it properly once its tab is opened. + Both the 'paginate' and the 'nav-on-click' class do so, the latter through clickableTable(). + Either one would build whichever table sits in the tab that is not open, using default options and no data source, + and the page could then no longer initialise that table once its tab is opened, leaving it empty until a reload. """ user = find_user_by_email("test_prosumer_user@seita.nl") asset = user.account.generic_assets[0] db.session.expunge(user) + with client.session_transaction() as session: + session["status_page_tab"] = remembered_tab status_page = client.get( url_for("AssetCrudUI:status", id=asset.id), follow_redirects=True ) @@ -703,3 +707,9 @@ def test_status_page_tables_are_not_auto_paginated( rb']*>' % table_id, status_page.data ).group() assert b"paginate" not in table_tag, table_tag + assert b"nav-on-click" not in table_tag, table_tag + # The jobs rows stay navigable, by the page applying that helper itself once it has built the table. + assert ( + b'clickableTable(document.getElementById("jobsTable"), "URL")' + in status_page.data + ) From cc1654e7121f9c0d81cc308b5325b09e7b75083f Mon Sep 17 00:00:00 2001 From: Ahmad-Wahid Date: Thu, 3 Sep 2026 12:32:04 +0200 Subject: [PATCH 8/8] docs: point the status page changelog entry at its PR Co-Authored-By: Claude Opus 5 --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index b6dd291bac..09c3ebf1ab 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -17,7 +17,7 @@ v1.1.0 | September XX, 2026 New features ------------- -* The asset's status page now splits its sensor data and its jobs over two tabs, of which only the opened one loads its data, and it opens the tab you last looked at [see `PR #2466 `_] +* The asset's status page now splits its sensor data and its jobs over two tabs, of which only the opened one loads its data, and it opens the tab you last looked at [see `PR #2470 `_] * Changing the selected time range on an asset or sensor chart now only loads the data that is actually new, instead of reloading the whole range, which makes stepping through or extending a long period much faster; reloading the page, or leaving it open for five minutes, still fetches everything afresh [see `PR #2433 `_] Infrastructure / Support