Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3b0708a
api/v3_0/sources: search the data sources listing, and narrow it to o…
BelhsanHmida Sep 23, 2026
e332a30
api/v3_0/assets: let an automation name the data source it computes u…
BelhsanHmida Sep 23, 2026
c247d30
ui/assets: pick a data source for a new automation, and see what it s…
BelhsanHmida Sep 23, 2026
3c3f822
ui/assets: copy an automation into the creation form
BelhsanHmida Sep 23, 2026
c9066cf
Merge origin/main into feat/automation-data-source-picker
BelhsanHmida Sep 24, 2026
e4e36ba
docs/changelog: point the data source picker entries at PR #2584
BelhsanHmida Sep 24, 2026
5b303c7
Merge the data source picker's rebuilt creation form into the copy bu…
BelhsanHmida Sep 24, 2026
110ed1e
Merge remote-tracking branch 'origin/feat/automation-data-source-pick…
BelhsanHmida Sep 24, 2026
94d4273
Merge remote-tracking branch 'origin/main' into feat/automation-data-…
BelhsanHmida Sep 24, 2026
81db15c
docs/changelog: fold the copy button into the automations UI entry
BelhsanHmida Sep 25, 2026
93f3c1e
docs/changelog: carry this work on the automations entry that already…
BelhsanHmida Sep 25, 2026
6d14ae9
docs/api: leave the automations endpoint to the release that introduc…
BelhsanHmida Sep 25, 2026
54902ef
data/schemas/automations: say what the source field is, not what it i…
BelhsanHmida Sep 25, 2026
b9ac7dd
data/schemas: let the source field be plainly optional, and leave Dat…
BelhsanHmida Sep 25, 2026
565b6cb
Merge origin/main into feat/automation-data-source-picker
Flix6x Sep 26, 2026
41eab51
Merge origin/main, and read an automation when a copy needs it
Flix6x Sep 26, 2026
19cddab
Merge the data source picker branch, whose entry now names its own pu…
Flix6x Sep 26, 2026
a52da13
ui/assets: move the automation form's reference text onto info icons
BelhsanHmida Sep 30, 2026
423d385
ui/assets: raise the form's tooltips above the modal they belong to
BelhsanHmida Sep 30, 2026
fbb6efa
ui/assets: say that the job counts reach back only as far as Redis ke…
BelhsanHmida Sep 30, 2026
4f6755e
Merge branch 'main' into feat/automation-data-source-picker
BelhsanHmida Sep 30, 2026
58382be
Merge the data source picker branch, which moved the form's help text
BelhsanHmida Sep 30, 2026
0d7cb82
Order the data source listing, let a client limit it, and keep its ty…
Flix6x Oct 1, 2026
ea9a9c4
Bound the source search, and open the automation form on a clean slate
Flix6x Oct 1, 2026
74f98e0
Read an empty source on an automation as no source at all
Flix6x Oct 1, 2026
68fa7d8
Changelog: the source an automation reuses, and searching the data so…
Flix6x Oct 1, 2026
cf2177c
Drop a local import of ValidationError which the module already has
Flix6x Oct 1, 2026
d8d77a5
Decide who may read a data source per source, rather than by its orga…
Flix6x Oct 1, 2026
424f093
Changelog and docs: which data sources are yours to read, and which t…
Flix6x Oct 1, 2026
eb687f2
Naming a source for an automation takes more than being allowed to re…
Flix6x Oct 1, 2026
926cca2
Address review: say which sources the types come from, and unwrap a c…
Flix6x Oct 1, 2026
949ab64
Merge remote-tracking branch 'origin/feat/automation-data-source-pick…
BelhsanHmida Oct 1, 2026
f897d00
Merge commit 'd0903d0d75dca35f23f2be75dcdead84a0a87db4' into feat/cop…
BelhsanHmida Oct 1, 2026
1de7852
Merge remote-tracking branch 'origin/main' into feat/copy-automation-…
BelhsanHmida Oct 1, 2026
edec10f
Changelog: one entry for the automations page's actions, not two
Flix6x Oct 3, 2026
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
2 changes: 1 addition & 1 deletion documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Automations arrived over several pull requests. This is what each of them contri
* Schedules as well as forecasts: a schedule automation stores what the schedule trigger endpoint accepts, and schedules from each run's own time [see `PR #2293 <https://www.github.com/FlexMeasures/flexmeasures/pull/2293>`_]
* A scheduler's data source now also records the flex config the scheduler computed under, so a schedule can be traced back to the configuration that produced it, and a schedule automation points at such a data source, the way a forecast automation points at its forecaster's [see `PR #2464 <https://www.github.com/FlexMeasures/flexmeasures/pull/2464>`_]
* A single automation can now be run on demand, from the CLI (``flexmeasures jobs run-automation``), the API (``POST /assets/<id>/automations/<automation-id>/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 <https://www.github.com/FlexMeasures/flexmeasures/pull/2460>`_]
* Automations can be created, edited and deleted in the UI and through new API endpoints (``[POST|PATCH|DELETE] /assets/(id)/automations``), by whoever may add data under the asset, with their recurrence expressed in a selectable IANA timezone, and only involving sensors they can access themselves (read access to the sensors an automation reads, and permission to record data on the sensors it writes to). The *Automations* page shows when each active automation is due to run next, as a clock time in the automation's own timezone, so a recurrence no longer has to be read back from its cron string; the same time is available as ``next_run`` on the automations API endpoints, and is null while an automation is inactive. It is the next scheduled clock time, so it excludes catch-up work still pending, and it follows the dispatcher's daylight-saving rules, taking the first fold of a repeated local time and the first valid minute after a skipped one. Each row's *Run now*, *Edit*, *Activate*/*Deactivate* and *Delete* controls are collected into a single *Actions* menu, leaving *Details* beside it, so a long listing carries two controls per row instead of five [see `PR #2294 <https://www.github.com/FlexMeasures/flexmeasures/pull/2294>`_ and `PR #2563 <https://www.github.com/FlexMeasures/flexmeasures/pull/2563>`_]
* Automations can be created, edited and deleted in the UI and through new API endpoints (``[POST|PATCH|DELETE] /assets/(id)/automations``), by whoever may add data under the asset, with their recurrence expressed in a selectable IANA timezone, and only involving sensors they can access themselves (read access to the sensors an automation reads, and permission to record data on the sensors it writes to). The *Automations* page shows when each active automation is due to run next, as a clock time in the automation's own timezone, so a recurrence no longer has to be read back from its cron string; the same time is available as ``next_run`` on the automations API endpoints, and is null while an automation is inactive. It is the next scheduled clock time, so it excludes catch-up work still pending, and it follows the dispatcher's daylight-saving rules, taking the first fold of a repeated local time and the first valid minute after a skipped one. Each row's *Run now*, *Edit*, *Copy*, *Activate*/*Deactivate* and *Delete* controls are collected into a single *Actions* menu, leaving *Details* beside it, so a long listing carries two controls per row instead of five. *Copy* opens the creation form filled in from that automation, and leaves the copy inactive [see `PR #2294 <https://www.github.com/FlexMeasures/flexmeasures/pull/2294>`_, `PR #2563 <https://www.github.com/FlexMeasures/flexmeasures/pull/2563>`_ and `PR #2591 <https://www.github.com/FlexMeasures/flexmeasures/pull/2591>`_]
* An automation's output sensors are checked against its creator's permissions when the automation is created, and the schedules it computes are held to exactly those sensors: a scheduler that returns results for any other sensor is refused, rather than recording on a sensor that was only ever checked for read access [see `PR #2536 <https://www.github.com/FlexMeasures/flexmeasures/pull/2536>`_]
* Reports as well as forecasts and schedules: a report automation stores report parameters, with its reporter and the reporter's configuration on a data source, and reports on a period resolved afresh on each run, either from ``start-offset`` and ``end-offset`` applied to the run time in the automation's timezone, or since the last successful report ended, while a fixed ``start`` or ``end`` is refused; a report job records only on the sensors the automation was checked against [see `PR #2297 <https://www.github.com/FlexMeasures/flexmeasures/pull/2297>`_]
* Every automation times its runs the same way: a fixed ``start``, ``end`` or ``prior`` in its parameters is refused, as every run would share that moment, and two of ``start-offset``, ``end-offset`` and ``duration`` describe the period each run covers instead, with the offsets applied to the time the run was due on the automation's own clock, so that, for instance, a schedule automation can plan the whole of the next day [see `PR #2551 <https://www.github.com/FlexMeasures/flexmeasures/pull/2551>`_]
Expand Down
4 changes: 4 additions & 0 deletions documentation/features/automations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,10 @@ An automation's *Info* panel shows the sensors it reads from and writes to, link
It also summarizes the automation's recent runs and their outcomes (see :ref:`automation_runs`).
Conversely, a sensor's page lists the automations that write data to it.

Each row's *Actions* menu also offers *Copy*, which opens the creation form with that automation's type, recurrence, timezone and parameters filled in, and its data source already selected.
It is a starting point for a variation on an existing automation, such as the same forecast on a different recurrence, rather than a command of its own: nothing is created until you submit the form, and you can change anything in it first.
The copy is created inactive, so that it does not start running while you are still working on it, and it is named after the original with a ``(copy)`` suffix.

.. _automation_cursor:

Appendix: how the runner decides what is due
Expand Down
135 changes: 126 additions & 9 deletions flexmeasures/ui/templates/assets/asset_automations.html
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ <h4>

{% if user_can_manage_automations %}
<div class="mb-2 text-end">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newAutomationModal">New automation</button>
<button class="btn btn-primary" id="newAutomationButton" data-bs-toggle="modal" data-bs-target="#newAutomationModal">New automation</button>
</div>

<datalist id="automationTimezones">
Expand Down Expand Up @@ -334,6 +334,8 @@ <h5 class="modal-title">Edit automation</h5>
const userCanRunAutomations = {{ user_can_create_children | tojson }};
const automationTypes = {{ automation_types | tojson }};
const automationsById = new Map();
// What the details endpoint said about each automation, kept so that copying one does not have to ask again.
const automationDetailsById = new Map();
let includeChildAssets = {{ 'true' if include_child_assets else 'false' }};
// How often the listing catches up with the server, while the tab is in front and nothing is open.
const REFRESH_INTERVAL_MS = 60000;
Expand Down Expand Up @@ -476,6 +478,68 @@ <h5 class="modal-title">Edit automation</h5>
setGeneratorFieldsEnabled(true);
}

/* A forecast and a report each run a data generator the creator chooses and configures;
a schedule automation's generator follows from the asset and its flex config instead.
The rule sits here, rather than in the ready callback, because filling the form in from an existing automation applies it too. */
function typeChoosesGenerator() {
return $("#automationType").val() !== "scheduling";
}

function showGeneratorFields() {
$(".chooses-generator").toggle(typeChoosesGenerator());
}

/* Empty the creation form, so that the next automation starts from the defaults the form was rendered with.
The form is shared with the Copy action, which leaves the previous automation's settings in it,
so opening it blank has to clear them rather than trust that they were never there. */
function resetNewAutomationForm() {
document.getElementById("newAutomationForm").reset();
// Resetting restores the type the form was rendered with, and the generator fields follow whichever type that is.
clearSelectedSource();
showGeneratorFields();
$("#newAutomationErr").addClass("d-none").text("");
}

function prefillNewAutomationForm(values) {
resetNewAutomationForm();
$("#automationName").val(values.name);
$("#automationType").val(values.type);
// The type decides whether a data generator can be named at all, so the fields follow it before anything is filled in.
showGeneratorFields();
$("#automationCron").val(values.cron);
$("#automationTimezone").val(values.timezone);
$("#automationParameters").val(values.parameters);
$("#automationActive").prop("checked", values.active);
}

// The name column allows 80 characters, and so does the API, so a long name loses its tail rather than the suffix that marks the copy.
const MAX_AUTOMATION_NAME_LENGTH = 80;
const AUTOMATION_COPY_SUFFIX = " (copy)";

function copiedAutomationName(name) {
const room = MAX_AUTOMATION_NAME_LENGTH - AUTOMATION_COPY_SUFFIX.length;
return `${String(name ?? "").slice(0, room)}${AUTOMATION_COPY_SUFFIX}`;
}

/* What the creation form should hold to recreate an automation on this same asset.
The copy starts inactive, so that a variation being worked on does not start running while it is still being edited,
and so that two automations writing the same results never appear by a single click.
The sensors the parameters name stay valid, as the copy stays on this asset, so they are carried over as they are stored.
A forecast or report automation reuses the original's data source, which already stores the data generator and its configuration;
a schedule automation resolves its own source on every run, so it names none. */
function automationCopyValues(automation, details) {
const sourceId = details?.source?.id;
return {
name: copiedAutomationName(automation.name),
type: automation.type,
cron: automation["cron"],
timezone: automation.timezone,
parameters: JSON.stringify(details?.parameters ?? {}, null, 4),
active: false,
sourceId: SOURCE_TYPE_PER_AUTOMATION_TYPE[automation.type] && sourceId ? sourceId : null,
};
}

// Run this automation once, now, on top of its recurring runs.
function runButton(automation) {
if (!userCanRunAutomations) {
Expand All @@ -492,6 +556,8 @@ <h5 class="modal-title">Edit automation</h5>
` : "";
const manageItems = userCanManage ? `
<li><button type="button" class="dropdown-item automation-edit" data-id="${automation.id}">Edit</button></li>
<li><button type="button" class="dropdown-item automation-copy" data-id="${automation.id}"
title="Open the creation form with this automation's settings filled in, to create a variation on it. The copy starts inactive.">Copy</button></li>
<li><button type="button" class="dropdown-item automation-toggle" data-id="${automation.id}" data-active="${automation.active}">
${automation.active ? "Deactivate" : "Activate"}
</button></li>
Expand Down Expand Up @@ -628,6 +694,9 @@ <h5 class="modal-title">Automation '${esc(automation.name)}' (ID: ${automation.i
url: automationUrl(automationId),
method: "GET",
success: function (res) {
// Copying an automation reads its parameters and its data source from here, rather than asking for them again.
automationDetailsById.set(automationId, res);

const jobStats = res["job-stats"] || {};
$(`#automation-jobs-${automationId}`).text(jobStatsText(jobStats));

Expand Down Expand Up @@ -907,6 +976,62 @@ <h6>Recently created jobs
bootstrap.Modal.getOrCreateInstance(document.getElementById("editAutomationModal")).show();
});

// Open the creation form filled in from an existing automation, to create a variation on it.
function copyAutomationFrom(automation, details) {
const values = automationCopyValues(automation, details);
prefillNewAutomationForm(values);
bootstrap.Modal.getOrCreateInstance(document.getElementById("newAutomationModal")).show();
if (values.sourceId) {
// The form is already open while this loads, so a slow read of the source does not hold up the rest of the copy.
$.ajax({
url: `/api/v3_0/sources/${values.sourceId}`,
method: "GET",
success: (source) => showSelectedSource(source),
error: function (xhr) {
// Without the source, the copy would fall back to a default data generator rather than the original's, so say so plainly.
$("#newAutomationErr").removeClass("d-none").text(
`Could not read data source ${values.sourceId}, which this automation computes under:`
+ ` ${automationAjaxErrorMessage(xhr)}.`
+ " Name a data generator and its configuration below, or the copy will use the default one."
);
},
});
}
}

$(document).on("click", ".automation-copy", function () {
const automationId = Number($(this).data("id"));
const automation = automationsById.get(automationId);
if (!automation) {
showToast("Could not find the automation to copy. Refresh the page to try again.", "error");
return;
}
const details = automationDetailsById.get(automationId);
if (details) {
copyAutomationFrom(automation, details);
return;
}
/* The page asks about one automation only when its panel is opened (see `loadAutomationInfo`),
so a copy reads the parameters and the data source of this one now. */
$.ajax({
url: automationUrl(automationId),
method: "GET",
success: function (res) {
automationDetailsById.set(automationId, res);
copyAutomationFrom(automation, res);
},
error: function (xhr) {
showToast(
`Could not read this automation to copy it: ${automationAjaxErrorMessage(xhr)}.`,
"error"
);
},
});
});

// Opening the form from the New automation button starts from the defaults, not from whatever a copy left in it.
$("#newAutomationButton").on("click", () => resetNewAutomationForm());

$("#editAutomationForm").submit(function (event) {
event.preventDefault();
const automationId = $("#editAutomationId").val();
Expand Down Expand Up @@ -954,14 +1079,6 @@ <h6>Recently created jobs
});
});

/* A forecast and a report each run a data generator the creator chooses and configures;
a schedule automation's generator follows from the asset and its flex config instead. */
function typeChoosesGenerator() {
return $("#automationType").val() !== "scheduling";
}
function showGeneratorFields() {
$(".chooses-generator").toggle(typeChoosesGenerator());
}
$("#automationType").on("change", function () {
showGeneratorFields();
// The sources offered are of the automation's own type, so a selection made for another type no longer applies.
Expand Down
6 changes: 3 additions & 3 deletions flexmeasures/ui/tests/js/test_automation_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def test_automation_actions_are_grouped_and_permission_gated(assert_js):
recurrence_description: "At 06:00", next_run: "2026-09-15T04:00:00+00:00",
}};
const row = manager.AutomationRow(automation);
check("one menu holds all four controls", ["run-automation", "automation-edit", "automation-toggle", "automation-delete"]
check("one menu holds all five controls", ["run-automation", "automation-edit", "automation-copy", "automation-toggle", "automation-delete"]
.every(name => row.actions.includes(name)), row.actions);
check("run now is no longer mixed with info", !row.info.includes("run-automation"), row.info);
check("info stays its own button", row.info.includes("automation-info"), row.info);
Expand All @@ -84,8 +84,8 @@ def test_automation_actions_are_grouped_and_permission_gated(assert_js):
const toggle = holder.querySelector(".automation-actions .dropdown-toggle");
check("the row offers a single Actions toggle", toggle !== null
&& holder.querySelectorAll(".automation-actions > .btn").length === 1, row.actions);
check("the four actions sit in its menu as items",
holder.querySelectorAll(".automation-actions .dropdown-menu .dropdown-item").length === 4,
check("the five actions sit in its menu as items",
holder.querySelectorAll(".automation-actions .dropdown-menu .dropdown-item").length === 5,
row.actions);
const inactive = manager.AutomationRow({{...automation, active: false, next_run: null}});
check("inactive action says Activate", inactive.actions.includes("Activate") && !inactive.actions.includes("Deactivate"), inactive.actions);
Expand Down
Loading
Loading