Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions flexmeasures/api/v3_0/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
create_automation,
delete_automation as remove_automation,
describe_cronstr,
get_asset_automations_job_stats,
get_automation_job_stats,
update_automation,
)
Expand Down Expand Up @@ -1237,10 +1238,12 @@ def get_automations(self, id: int, asset: GenericAsset):
get:
summary: Get all automations defined on an asset.
description: |
The response will be a list of automations: recurring tasks (for now, computing forecasts)
defined on the asset. Each entry shows the automation's ID, when it was created,
its type, name, activation status, and its recurrence, both as a cron string
and described in natural language.
The response will be a list of automations: recurring tasks (computing forecasts,
schedules or reports) defined on the asset. Each entry shows the automation's ID,
when it was created, its type, name, activation status, its recurrence (both as a
cron string and described in natural language), and counts of recently created
jobs per job status (note that jobs in Redis have a limited TTL, so not all past
jobs are counted).
security:
- ApiKeyAuth: []
parameters:
Expand Down Expand Up @@ -1268,6 +1271,9 @@ def get_automations(self, id: int, asset: GenericAsset):
cronstr: "0 6 * * *"
recurrence_description: "At 06:00"
active: true
job_stats:
finished: 3
redis_connection_err: null
400:
description: INVALID_REQUEST, REQUIRED_INFO_MISSING, UNEXPECTED_PARAMS
401:
Expand All @@ -1279,14 +1285,24 @@ def get_automations(self, id: int, asset: GenericAsset):
tags:
- Assets
"""
redis_connection_err = None
try:
job_stats = get_asset_automations_job_stats(asset)
except NoRedisConfigured as e:
job_stats = {}
redis_connection_err = e.args[0]
automations_data = []
for automation in asset.automations:
automation_data = automation_schema.dump(automation)
automation_data["recurrence_description"] = describe_cronstr(
automation.cronstr
)
automation_data["job_stats"] = job_stats.get(automation.id, {})
automations_data.append(automation_data)
return {"automations": automations_data}, 200
return {
"automations": automations_data,
"redis_connection_err": redis_connection_err,
}, 200

@route("/<id>/automations/<int:automation_id>", methods=["GET"])
@use_kwargs(
Expand Down
1 change: 1 addition & 0 deletions flexmeasures/api/v3_0/tests/test_automations_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def test_get_automations(
assert day_ahead["recurrence_description"] == "At 06:00"
assert day_ahead["active"] is True
assert day_ahead["created_at"] is not None
assert day_ahead["job_stats"] == {} # this automation has not queued any jobs
# generator and parameters are not listed
assert "generator_id" not in day_ahead
assert "generator" not in day_ahead
Expand Down
65 changes: 50 additions & 15 deletions flexmeasures/data/services/automations.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,14 @@ def _relevant_sensor_ids(automation: Automation, parameter_values: list) -> set[
return sensor_ids


def get_automation_job_stats(automation: Automation) -> dict[str, int]:
"""Count the jobs created by this automation, per job status.
def _job_cache_refs(automation: Automation) -> set[tuple[int, str, str]]:
"""The job cache entries where this automation's jobs may live.

Note that jobs in Redis have a limited TTL, so this only counts fairly recent jobs.
Forecasting and reporting jobs are cached under their target/output sensor(s),
which may belong to a different asset than the automation's own asset.
Scheduling jobs are cached under the asset (multi-device wrap-up jobs)
and under individual sensors (per-device jobs).
"""
from flask import current_app

# Determine the job cache entries to scan. Forecasting and reporting jobs
# are cached under their target/output sensor(s), which may belong to a
# different asset than the automation's own asset.
parameters = automation.parameters or {}
if automation.type == "schedules":
# Scheduling jobs are cached under the asset (multi-device wrap-up jobs)
Expand All @@ -251,9 +249,9 @@ def get_automation_job_stats(automation: Automation) -> dict[str, int]:
if isinstance(entry, dict)
],
)
cache_refs = [(automation.asset_id, "scheduling", "asset")] + [
return {(automation.asset_id, "scheduling", "asset")} | {
(sensor_id, "scheduling", "sensor") for sensor_id in sensor_ids
]
}
elif automation.type == "reports":
sensor_ids = _relevant_sensor_ids(
automation,
Expand All @@ -263,27 +261,64 @@ def get_automation_job_stats(automation: Automation) -> dict[str, int]:
if isinstance(output, dict)
],
)
cache_refs = [(sensor_id, "reporting", "sensor") for sensor_id in sensor_ids]
return {(sensor_id, "reporting", "sensor") for sensor_id in sensor_ids}
else:
sensor_ids = _relevant_sensor_ids(
automation,
[parameters.get("sensor"), parameters.get("sensor-to-save")],
)
cache_refs = [(sensor_id, "forecasting", "sensor") for sensor_id in sensor_ids]
return {(sensor_id, "forecasting", "sensor") for sensor_id in sensor_ids}


counts: dict[str, int] = {}
def _count_automation_jobs(
cache_refs: set[tuple[int, str, str]], automation_ids: set[int]
) -> dict[int, dict[str, int]]:
"""Count jobs per automation and job status, in a single pass over the given job cache entries."""
from flask import current_app

counts: dict[int, dict[str, int]] = {
automation_id: {} for automation_id in automation_ids
}
seen_job_ids: set[str] = set()
for entity_id, queue, asset_or_sensor_type in cache_refs:
for job in current_app.job_cache.get(entity_id, queue, asset_or_sensor_type):
if job.id in seen_job_ids:
continue
seen_job_ids.add(job.id)
if job.meta.get("trigger", {}).get("automation_id") == automation.id:
automation_id = job.meta.get("trigger", {}).get("automation_id")
if automation_id in counts:
status = str(job.get_status().value)
counts[status] = counts.get(status, 0) + 1
counts[automation_id][status] = counts[automation_id].get(status, 0) + 1
return counts


def get_automation_job_stats(automation: Automation) -> dict[str, int]:
"""Count the jobs created by this automation, per job status.

Note that jobs in Redis have a limited TTL, so this only counts fairly recent jobs.
"""
return _count_automation_jobs(_job_cache_refs(automation), {automation.id})[
automation.id
]


def get_asset_automations_job_stats(asset) -> dict[int, dict[str, int]]:
"""Count the jobs created by each of the asset's automations, per job status,
in a single pass over the relevant job cache entries.

Note that jobs in Redis have a limited TTL, so this only counts fairly recent jobs.
"""
automations = asset.automations
if not automations:
return {}
cache_refs: set[tuple[int, str, str]] = set()
for automation in automations:
cache_refs |= _job_cache_refs(automation)
return _count_automation_jobs(
cache_refs, {automation.id for automation in automations}
)


def _prepare_forecast_automation(
asset, parameters: dict, generator_class: str | None, config: dict | None, source
) -> tuple[int, list[str]]:
Expand Down
10 changes: 7 additions & 3 deletions flexmeasures/ui/static/openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -3298,7 +3298,7 @@
"/api/v3_0/assets/{id}/automations": {
"get": {
"summary": "Get all automations defined on an asset.",
"description": "The response will be a list of automations: recurring tasks (for now, computing forecasts)\ndefined on the asset. Each entry shows the automation's ID, when it was created,\nits type, name, activation status, and its recurrence, both as a cron string\nand described in natural language.\n",
"description": "The response will be a list of automations: recurring tasks (computing forecasts,\nschedules or reports) defined on the asset. Each entry shows the automation's ID,\nwhen it was created, its type, name, activation status, its recurrence (both as a\ncron string and described in natural language), and counts of recently created\njobs per job status (note that jobs in Redis have a limited TTL, so not all past\njobs are counted).\n",
"security": [
{
"ApiKeyAuth": []
Expand Down Expand Up @@ -3333,9 +3333,13 @@
"name": "Day-ahead PV forecasts",
"cronstr": "0 6 <em> </em> *",
"recurrence_description": "At 06:00",
"active": true
"active": true,
"job_stats": {
"finished": 3
}
}
]
],
"redis_connection_err": null
}
}
}
Expand Down
96 changes: 79 additions & 17 deletions flexmeasures/ui/templates/assets/asset_automations.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,35 @@ <h5 class="modal-title">New automation for {{ asset.name }}</h5>
</form>
</div></div>
</div>

<!-- EDIT AUTOMATION MODAL -->
<div class="modal fade" id="editAutomationModal" tabindex="-1" role="dialog">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit automation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editAutomationForm">
<input type="hidden" id="editAutomationId">
<div class="modal-body">
<div class="mb-3">
<label for="editAutomationName" class="form-label">Name</label>
<input type="text" class="form-control" id="editAutomationName" maxlength="80" required>
</div>
<div class="mb-3">
<label for="editAutomationCron" class="form-label">Recurrence (cron string)</label>
<input type="text" class="form-control" id="editAutomationCron" required>
<div class="form-text">In the platform timezone, e.g. "0 6 * * *" for daily at 6:00.</div>
</div>
<div class="alert alert-danger d-none" id="editAutomationErr"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div></div>
</div>
{% endif %}

<ul class="nav nav-tabs" role="tablist">
Expand Down Expand Up @@ -135,6 +164,12 @@ <h5 class="modal-title">New automation for {{ asset.name }}</h5>
}[c]));
}

function jobStatsText(jobStats) {
return Object.keys(jobStats || {}).length
? Object.entries(jobStats).map(([status, count]) => `${status}: ${count}`).join(", ")
: "none";
}

function AutomationRow(automation) {
const createdAt = automation.created_at;
return {
Expand All @@ -147,8 +182,8 @@ <h5 class="modal-title">New automation for {{ asset.name }}</h5>
`,
active: `<span title="${automation.active ? 'Active' : 'Inactive'}">${automation.active ? "🟢" : "⚪"}</span>`,
recurrence: `<span title="${esc(automation.cronstr)}">${esc(automation.recurrence_description)}</span>`,
jobs: `<span id="automation-jobs-${automation.id}" title="Recently created jobs, per job status"></span>`,
details: `<a href="#" class="btn btn-default btn-success" data-bs-target="#AutomationDetailsModal-${automation.id}" data-bs-toggle="modal">Details</a>
jobs: `<span title="Recently created jobs, per job status">${esc(jobStatsText(automation.job_stats))}</span>`,
details: `<a href="#" class="btn btn-default btn-success automation-details" data-id="${automation.id}" data-bs-target="#AutomationDetailsModal-${automation.id}" data-bs-toggle="modal">Details</a>
<div class="modal fade" id="AutomationDetailsModal-${automation.id}" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg"><div class="modal-content">
<div class="modal-header">
Expand All @@ -159,6 +194,7 @@ <h5 class="modal-title">Automation '${esc(automation.name)}' (ID: ${automation.i
</div></div>
</div>`,
actions: `
<button class="btn btn-sm btn-outline-secondary automation-edit" data-id="${automation.id}" data-name="${esc(automation.name)}" data-cron="${esc(automation.cronstr)}">Edit</button>
<button class="btn btn-sm btn-outline-secondary automation-toggle" data-id="${automation.id}" data-active="${automation.active}">
${automation.active ? "Deactivate" : "Activate"}
</button>
Expand All @@ -167,19 +203,16 @@ <h5 class="modal-title">Automation '${esc(automation.name)}' (ID: ${automation.i
};
}

const loadedDetails = new Set();

function loadAutomationDetails(automationId) {
// load the details modal contents once, when first opened
if (loadedDetails.has(automationId)) return;
loadedDetails.add(automationId);
$.ajax({
url: `/api/v3_0/assets/${assetId}/automations/${automationId}`,
method: "GET",
success: function (res) {
// Show job stats in the table row
const jobStats = res.job_stats || {};
const statsStr = Object.keys(jobStats).length
? Object.entries(jobStats).map(([status, count]) => `${status}: ${count}`).join(", ")
: "none";
$(`#automation-jobs-${automationId}`).text(statsStr);

// Fill the details modal
const generator = res.generator
? `${res.generator.description} (data source ${res.generator.id})`
: "None";
Expand All @@ -189,14 +222,11 @@ <h6>Data generator</h6>
<h6>Parameters</h6>
<pre>${esc(JSON.stringify(res.parameters, null, 4))}</pre>
<h6>Recently created jobs</h6>
<pre>${esc(JSON.stringify(jobStats, null, 4))}</pre>
<pre>${esc(JSON.stringify(res.job_stats || {}, null, 4))}</pre>
`);
if (res.redis_connection_err) {
$("#automations_err").removeClass("d-none").text(res.redis_connection_err);
}
},
error: function (xhr) {
$(`#automation-jobs-${automationId}`).text("unknown");
loadedDetails.delete(automationId); // allow retrying
$(`#AutomationDetailsBody-${automationId}`).text("Could not load details.");
},
});
Expand All @@ -223,8 +253,6 @@ <h6>Recently created jobs</h6>
],
data: automations.map(AutomationRow),
});
// Load more details per automation asynchronously
automations.forEach(automation => loadAutomationDetails(automation.id));
}

function apiError(xhr, fallbackMessage) {
Expand All @@ -242,6 +270,9 @@ <h6>Recently created jobs</h6>
url: `/api/v3_0/assets/${assetId}/automations`,
method: "GET",
success: function (res) {
if (res.redis_connection_err) {
$("#automations_err").removeClass("d-none").text(res.redis_connection_err);
}
for (const automationType of ["forecasts", "schedules", "reports"]) {
makeAutomationsTable(
automationType,
Expand All @@ -257,6 +288,37 @@ <h6>Recently created jobs</h6>
},
});

// Load the details modal contents when first opened
$(document).on("click", ".automation-details", function () {
loadAutomationDetails($(this).data("id"));
});

// Edit an automation (name and recurrence)
$(document).on("click", ".automation-edit", function () {
$("#editAutomationId").val($(this).data("id"));
$("#editAutomationName").val($(this).data("name"));
$("#editAutomationCron").val($(this).data("cron"));
$("#editAutomationErr").addClass("d-none").text("");
new bootstrap.Modal("#editAutomationModal").show();
});
$("#editAutomationForm").submit(function (event) {
event.preventDefault();
const automationId = $("#editAutomationId").val();
$.ajax({
url: `/api/v3_0/assets/${assetId}/automations/${automationId}`,
method: "PATCH",
contentType: "application/json",
data: JSON.stringify({
name: $("#editAutomationName").val(),
cronstr: $("#editAutomationCron").val(),
}),
success: () => location.reload(),
error: function (xhr) {
$("#editAutomationErr").removeClass("d-none").text(apiError(xhr, "Could not update the automation."));
},
});
});

// (De)activate an automation
$(document).on("click", ".automation-toggle", function () {
const automationId = $(this).data("id");
Expand Down
Loading