diff --git a/documentation/changelog.rst b/documentation/changelog.rst index d59bd84fc2..28cefc5c44 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -18,6 +18,7 @@ New features ------------- * A single automation can now be run on demand, from the CLI (``flexmeasures jobs run-automation``), the API (``POST /assets//automations//trigger``) and the asset's *Automations* page (a *Run now* button), which is useful to try out a new automation, to re-run one after fixing what made it fail, or to refresh its results after late input data arrived [see `PR #2460 `_] +* 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 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 a51cd37cec..b4ddcb2851 100644 --- a/flexmeasures/api/v3_0/assets.py +++ b/flexmeasures/api/v3_0/assets.py @@ -323,6 +323,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) @@ -1832,6 +1843,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/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/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 4f156b8c06..07fd2d6652 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4861,6 +4861,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", @@ -6337,6 +6398,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..de97f3890a 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,59 @@
- -

- 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 +137,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 +196,79 @@ ); }, }); + } + + 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: [] }); + }, + }); + }, + }); + // 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) { + 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 +321,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 +338,7 @@ }); } - $("#refresh_jobs").click(() => jobsTable.ajax.reload()); + $("#refresh_jobs").click(() => jobsTable && jobsTable.ajax.reload()); setInterval(updateTimeAgoDisplays, 10000); }); diff --git a/flexmeasures/ui/tests/test_asset_crud.py b/flexmeasures/ui/tests/test_asset_crud.py index edc48a4d7a..3344be51e7 100644 --- a/flexmeasures/ui/tests/test_asset_crud.py +++ b/flexmeasures/ui/tests/test_asset_crud.py @@ -652,3 +652,64 @@ 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']*>' % 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 + )