This commit is contained in:
Mateusz Gruszczyński
2026-09-02 23:10:44 +02:00
parent 563523d2e2
commit 76bbd39378
16 changed files with 196 additions and 57 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.11.1"
version = "0.11.2"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.11.1"
version = "0.11.2"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+15 -15
View File
@@ -1,12 +1,12 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
0908ab9051cecea10d593240f22cab54ea1c5b7111f9090037fa03fe4a009df9 ./Cargo.lock
81dfad1866f4e431282fb7e7b67367fbd01f983db593358c27ffbd2100b7cf84 ./Cargo.toml
a40c2764fb504ebe70c8cd1cf7f1f1c9f5f870a5e51e5a4711eac6721d6cc09b ./Cargo.lock
aa1d1bc6b03f0c0f4719a0c6dcb0725980da2ce9dc37f3476b14372da3468f6a ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
dc9711f3c80bde54d4db52960dc1fc6583893bb52b76727ac261d9bbb7c3c3f9 ./README.md
f2ff4364706d1906e9961c56f311fb056c166f63a1b0ec184e844bc02be9c33f ./README.md
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
7c096c62872e570a3fce80253497a42c18248570e785dfad9acefe89bf597e25 ./docs/API.md
2de6eea12eb0ab5ef25eb220bc3c161e31419531b64e8eb761191e9ab3c4d3ea ./docs/FLOW.md
a3708b5708784936ff2509ccf094fc9926071560452cb0a07e5d3d283e265d7e ./docs/API.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
@@ -26,8 +26,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
12882bf4351aa5e0f6adac4ccc61205a3f95c4f1ec9853d322ee7b9a4e93d651 ./lang/en.json
a4f5b11e058b267f9ba60acdc447d401f4d95d1180af786c02c3f794a83a6146 ./lang/pl.json
2cd6bca7f5aa2a4e59f789c5c55229aab7ba52c1f2b9d5742c8f17162b0c4f8f ./lang/en.json
13f056aa147c3adb301b7d935adf498bbbf6d6906ec3eb96b3e39cd4c0e90fce ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -67,7 +67,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
809cad38164d2775d8eea4c72f308b4448642863bec30b531e0e9dcb8f9da418 ./scripts/FILE_MANIFEST.sha256
5a8125836ed0c6e0fd98ea7e24867760771210da9f80c98d8d6ec3bcc6c5b150 ./scripts/FILE_MANIFEST.sha256
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
@@ -80,7 +80,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
b70f2a66f39ed050f9030ed499f74e8e899e224d6d7c47ed2a1389ce1be11421 ./scripts/verify_flow_logic.py
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./scripts/verify_flow_logic.py
da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
@@ -88,7 +88,7 @@ da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
40a1d010371eb7c5c90ddd881c86f8023bb61ff77eeec83fabd0759d38e241cb ./src/api/flows.rs
1bbe45c11b176d1595007942ef7723c7954c60fe20cc5aa18c4dbab00b6f09ff ./src/api/flows.rs
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
@@ -96,7 +96,7 @@ b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/hous
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs
a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs
09ac8aae6e51f36379908e48b3b18d845d3c0b246277ed945c29e0c648197c4a ./src/api/settings.rs
430f446371489a2b2f1532d589c71f60c98c1a92765ccb5e486b08e2da8f31c3 ./src/api/settings.rs
bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
@@ -142,7 +142,7 @@ c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.r
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
42f968d9a99ac154b0bd83e5baa498659b1378be4d47d2a05665be641a2fb938 ./src/models/flow.rs
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
552dfc1717ea88f3a23b1fb7705f1dbb0df7792ca9a6ae7ad7bd275eacef27af ./src/models/integrations.rs
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
@@ -176,11 +176,11 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
72fa280996ba331a05bdb29a1f49b4e61bdd8964953c8ce1db952156620f08fe ./web/js/charts.js
f07600277c60c771c04b1d43c5ed08de6bf518a72660011767b71cde8ec5bde8 ./web/js/core.js
782ce96f3ec006c447e8c44c60ce4142e0fd830704a6ef0511a66505c12cb6bf ./web/js/core.js
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
f53e2da5c48b55cf0191ffeb970a2c053c66e62d29099e3c5b5fccc8d89440cc ./web/js/entities.js
9a0e87911f78cd8a969f4c57ce74774f5fa829fdecd79494d9a7f9d5804f9792 ./web/js/events.js
e83d7646ea4f37b746ef550ded3de326ae22a5ae9539cc3cedb71f43d5e122db ./web/js/flows.js
8cbc2bc0f31721fbc441bd4be1008b430cae43d4a4035bf72b88eaf32a054c74 ./web/js/flows.js
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
@@ -190,6 +190,6 @@ c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/route
34513d9ec2e45c60443dffe028eea74f415b8a29016df6af3377a80a4ac0df5b ./web/js/settings-ui.js
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
ca526ef93c5b31a212ca71efdadeac4d80659b9a28f281011f461ae772c0272a ./web/styles.css
5f8c321809677a12f20bf270c17c7472f6dfc289c03d40c879b92ba10e3f6d6d ./web/styles.css
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+2 -2
View File
@@ -2,7 +2,7 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**Current release: 0.11.1**
**Current release: 0.11.2**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
@@ -426,4 +426,4 @@ The editor supports optimistic revisions, so saving an older copy from another b
Diagnostics include a non-mutating dry-run endpoint/UI with a selectable simulation time, optional per-block sensor/state overrides, a per-node condition trace and an ownership/block reason. Shared Home Assistant Flow inputs can also be tested directly in their editor against the current HA entity state. Flow-scoped execution and dry-run events can be viewed from the editor.
Individual Flows can be exported/imported as versioned `gree-controller-flow` JSON. Generated schedules and automations are intentionally excluded from the portable document and are regenerated from the source graph on import. Shared inputs show which Flows reference them and link directly to those editors. The preset library includes 37 categorized scenarios for comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
Individual Flows can be exported/imported as versioned `gree-controller-flow` JSON. Generated schedules and automations are intentionally excluded from the portable document and are regenerated from the source graph on import. Incomplete Flows can also be saved as disabled drafts; drafts preserve the editor graph but intentionally generate no schedules or automations until completed and saved normally. Shared inputs show which Flows reference them and link directly to those editors. The preset library includes 37 categorized scenarios for comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
+6 -6
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.11.1**.
HTTP and WebSocket API for GREE Controller **0.11.2**.
[← Main documentation](../README.md)
@@ -213,7 +213,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.11.1",
"version": "0.11.2",
"uptime_seconds": 1234,
"control_ready": true,
"time": "2026-08-30T06:54:00Z"
@@ -237,7 +237,7 @@ Returns the initial Web UI snapshot:
"settings": {},
"outdoor_temperature": null,
"system": {
"version": "0.11.1",
"version": "0.11.2",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1512,9 +1512,9 @@ curl -H 'Authorization: Bearer gree_controller_RESTRICTED_TOKEN' \
## Visual Flow API
Flow is the source-of-truth representation for visual schedule/automation logic. `GET /api/flows` lists Flows; `GET /api/flows/:id`, `POST /api/flows`, `PUT /api/flows/:id` and `DELETE /api/flows/:id` manage them. A Flow payload contains `name`, `enabled`, optional `description`, `nodes` and `edges`.
Flow is the source-of-truth representation for visual schedule/automation logic. `GET /api/flows` lists Flows; `GET /api/flows/:id`, `POST /api/flows`, `PUT /api/flows/:id` and `DELETE /api/flows/:id` manage them. A Flow payload contains `name`, `enabled`, optional `draft`, optional `description`, `nodes` and `edges`. `draft=true` is reserved for work-in-progress graphs: the backend forces them disabled and stores no generated schedules or automations.
Saving a Flow validates the DAG and compiles it atomically. A thermostat action driven only by one weekday block and one time-range block is emitted as a native schedule when its settings are schedule-compatible. More complex graphs are emitted as Flow-triggered automations. Generated schedules and automations expose `flow_id` / `flow_node_id`, use stable names in the form `flow-<stable-unique-id>`, and cannot be edited or deleted through their legacy endpoints; edit the owning Flow instead.
Saving an executable Flow validates the DAG and compiles it atomically. If the editor receives HTTP 400 during normal save, it can offer to retry with `draft=true`; draft validation preserves the editable graph while allowing missing actions or unfinished semantic wiring. Drafts never execute, and converting an existing Flow to a draft atomically removes its previously generated outputs. A thermostat action driven only by one weekday block and one time-range block is emitted as a native schedule when its settings are schedule-compatible. More complex graphs are emitted as Flow-triggered automations. Generated schedules and automations expose `flow_id` / `flow_node_id`, use stable names in the form `flow-<stable-unique-id>`, and cannot be edited or deleted through their legacy endpoints; edit the owning Flow instead.
Condition blocks support weekday, time/date ranges, optional 5-field CRON, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability sources, rolling mean/median, oscillation detection, and `shared_input` references. Stateful gate blocks include `stable_for`, `delay`, `state_duration` (`min_seconds`, optional `max_seconds`), `on_change` (`mode: result|value`) and `rate_limit` (`max_count`, `period_seconds`). `on_change` establishes a baseline on its first observation and does not fire immediately. `rate_limit` must feed an action directly and consumes quota only after a successful action execution. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time. Shared inputs store reusable value sources only. They never store a comparison operator or threshold. For comparison-capable source kinds, each `shared_input` Flow node defines its own `operator` and `value`. `PUT /api/settings` rejects shared-input configs that contain an `operator`, and rejects comparison `value` fields for comparison-capable source kinds. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices, climate groups or a generic Home Assistant service. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target.
@@ -1531,7 +1531,7 @@ Cancellation suppresses the same pending intent until a new explicit thermostat/
`GET /api/flows/:id/export` returns a versioned `gree-controller-flow` JSON document containing only the source graph. Generated schedules/automations are not exported.
`POST /api/flows/import` accepts either that envelope or a direct Flow source payload, creates a new Flow ID/revision, validates it and atomically recompiles outputs.
`POST /api/flows/import` accepts either that envelope or a direct Flow source payload, creates a new Flow ID/revision, and preserves draft state. Executable imports are validated and recompiled; draft imports remain disabled with no generated outputs.
`POST /api/flows/simulate` accepts `{ flow, flow_id?, at?, overrides?, log? }`. `at` is RFC3339. `overrides` maps Flow node IDs to simulated values. The endpoint validates and compiles the graph, evaluates every action and returns per-node traces plus `matched`, `would_execute` and `blocked_reason` (for example disabled zone/device, manual/local/temporary thermostat ownership, thermostat-output conflict or disabled Flow). It never mutates thermostat/device/group/schedule/automation state. Home Assistant read/attribute/parse failures evaluate safely as false and are visible in the trace instead of accidentally satisfying `NOT`/`neq` logic.
+2
View File
@@ -9,6 +9,8 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc
- Generated rows keep `flow_id` and `flow_node_id`, use stable `flow-<stable-unique-id>` names, and are read-only in the legacy editors.
- The Flow source graph plus all of its generated schedule/automation rows are replaced in one SQLite transaction.
- Flow updates require `expected_revision`; stale editor tabs receive HTTP 409.
- Work-in-progress graphs may be persisted with `draft=true`. Drafts are forced disabled, compile to zero schedules/automations, and can contain unfinished semantic wiring such as a graph without an action. Missing node references and duplicate/empty IDs are still rejected so the editor document remains structurally safe.
- Saving a previously executable Flow as a draft atomically deletes its generated outputs, so no stale task can keep running. Completing and saving it normally clears draft status and recompiles it.
## Existing control domains remain authoritative
+8 -1
View File
@@ -1093,6 +1093,13 @@
"flow.cronEveryMinutes": "CRON every {minutes} min",
"flow.cronHourlyAt": "CRON hourly at :{minute}",
"flow.cronDailyAt": "CRON daily at {time}",
"flow.cronCustom": "CRON {expression}"
"flow.cronCustom": "CRON {expression}",
"flow.draft": "Draft",
"flow.draftStatus": "Draft · not executable",
"flow.draftNoExecution": "Draft — disabled, with no executable tasks",
"flow.draftDisabledHint": "A draft is always disabled. Finish the Flow and save it again to create executable tasks.",
"flow.draftCannotEnable": "Finish and save the Flow first. A draft cannot be enabled.",
"flow.saveAsDraftConfirm": "The Flow cannot be saved as executable:\n\n{reason}\n\nSave it as a draft instead? The draft will be disabled and will not create any tasks or schedules.",
"flow.savedAsDraft": "Flow saved as a disabled draft. You can return to it later."
}
}
+8 -1
View File
@@ -1093,6 +1093,13 @@
"flow.cronEveryMinutes": "CRON co {minutes} min",
"flow.cronHourlyAt": "CRON co godzinę o :{minute}",
"flow.cronDailyAt": "CRON codziennie o {time}",
"flow.cronCustom": "CRON {expression}"
"flow.cronCustom": "CRON {expression}",
"flow.draft": "Szkic",
"flow.draftStatus": "Szkic · niewykonywany",
"flow.draftNoExecution": "Szkic — wyłączony, bez zadań wykonawczych",
"flow.draftDisabledHint": "Szkic jest zawsze wyłączony. Dokończ Flow i zapisz go ponownie, aby utworzyć zadania wykonawcze.",
"flow.draftCannotEnable": "Najpierw dokończ i zapisz Flow. Szkicu nie można włączyć.",
"flow.saveAsDraftConfirm": "Flow nie może zostać zapisany jako wykonywalny:\n\n{reason}\n\nZapisać go jako szkic? Szkic będzie wyłączony i nie utworzy żadnych zadań ani harmonogramów.",
"flow.savedAsDraft": "Flow zapisany jako wyłączony szkic. Możesz wrócić do niego później."
}
}
+1 -1
View File
@@ -10,4 +10,4 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debu
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
b70f2a66f39ed050f9030ed499f74e8e899e224d6d7c47ed2a1389ce1be11421 ./verify_flow_logic.py
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./verify_flow_logic.py
+17 -2
View File
@@ -421,6 +421,21 @@ def main():
check('if !item.enabled { continue; }' in engine and 'if !ready || !should_fire { continue; }' in engine, 'Flow runtime may be skipped during action cooldown')
check('rate-limit block must be placed directly before an action' in api, 'rate-limit placement guard missing')
check('on-change value mode needs one direct source/condition input' in api, 'on-change value-source guard missing')
models=(ROOT/'src/models/flow.rs').read_text()
core=(ROOT/'web/js/core.js').read_text()
settings_api=(ROOT/'src/api/settings.rs').read_text()
check('pub draft: bool' in models and '#[serde(default)]' in models, 'Flow draft persistence field missing')
check('fn validate_flow_draft_graph' in api, 'draft structural validator missing')
check(api.count('if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }') >= 3, 'draft create/update/import validation split missing')
check('enabled: if draft { false } else { input.enabled }' in api, 'draft does not force Flow disabled')
check(api.count('(prepare_draft_flow(flow), vec![], vec![])') >= 3, 'draft save/import may compile executable outputs')
check('flow.compiled_schedule_ids.clear()' in api and 'flow.compiled_automation_ids.clear()' in api, 'draft output clearing missing')
check('"draft": flow.draft' in api, 'draft state missing from Flow export')
check('import contains an executable Flow draft' in settings_api, 'configuration import does not enforce draft safety invariant')
check('error.status = response.status' in core, 'frontend API errors do not expose HTTP validation status')
check("error.status !== 400" in js and "draft:true" in js and "enabled:false" in js, 'save-as-draft retry flow missing')
check("if (flow.draft) return toast(tr('flow.draftCannotEnable')" in js, 'draft quick-enable guard missing')
check("draft: app.flowDraft.draft === true" in js, 'draft portability state missing from source payload')
runtime_leaf=engine.split('async fn flow_leaf_observation',1)[1].split('pub async fn evaluate_flow_conditions_trace',1)[0]
for kind in CONDITIONS - {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'}:
check(f'\"{kind}\" =>' in runtime_leaf or (kind == 'shared_input' and 'condition.kind == \"shared_input\"' in runtime_leaf), f'{kind}: runtime leaf implementation missing')
@@ -430,14 +445,14 @@ def main():
for category in ('trigger','time','timeop','sensor','logic','action','haaction'):
check(f'flow-palette-{category}' in html, f'palette category {category} missing')
css=(ROOT/'web/styles.css').read_text()
for marker in ('.flow-palette-group button', 'min-height:30px', 'padding:6px 8px', '.flow-palette-timeop', '.flow-palette-haaction'):
for marker in ('.flow-palette-group button', 'min-height:30px', 'padding:6px 8px', '.flow-palette-timeop', '.flow-palette-haaction', '.flow-editor-title input:hover', '.flow-editor-title input:focus', '.flow-draft-badge'):
check(marker in css, f'compact/color Flow CSS missing: {marker}')
# Translation pack correctness: all new keys must be inside translations, not root.
for lang in ('pl','en'):
pack=json.loads((ROOT/f'lang/{lang}.json').read_text())
check(not any(k.startswith('flow.') for k in pack), f'{lang}: flow translations leaked to root')
for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.stateDuration','flow.node.onChange','flow.node.rateLimit','flow.node.delay','flow.node.rollingStat','flow.node.oscillates','flow.node.haServiceAction'):
for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.stateDuration','flow.node.onChange','flow.node.rateLimit','flow.node.delay','flow.node.rollingStat','flow.node.oscillates','flow.node.haServiceAction','flow.draft','flow.draftStatus','flow.draftNoExecution','flow.draftDisabledHint','flow.draftCannotEnable','flow.saveAsDraftConfirm','flow.savedAsDraft'):
check(bool(pack.get('translations',{}).get(key)), f'{lang}: missing {key}')
title_keys=set(re.findall(r"titleKey: '([^']+)'", meta_block))
missing_titles=sorted(key for key in title_keys if not pack.get('translations',{}).get(key))
+85 -14
View File
@@ -4,6 +4,8 @@ struct FlowInput {
#[serde(default = "yes")]
enabled: bool,
#[serde(default)]
draft: bool,
#[serde(default)]
description: String,
#[serde(default)]
nodes: Vec<crate::models::FlowNode>,
@@ -107,9 +109,8 @@ fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) ->
Ok(())
}
fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
fn validate_flow_draft_graph(input: &FlowInput) -> Result<(), AppError> {
if input.name.trim().is_empty() { return Err(AppError::BadRequest("flow name is required".into())); }
if input.nodes.is_empty() { return Err(AppError::BadRequest("flow needs at least one block".into())); }
let mut ids = std::collections::HashSet::<String>::new();
for node in &input.nodes {
if node.id.trim().is_empty() || !ids.insert(node.id.clone()) { return Err(AppError::BadRequest("flow contains duplicate or empty block IDs".into())); }
@@ -117,14 +118,26 @@ fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
return Err(AppError::BadRequest(format!("unsupported flow block: {}", node.kind)));
}
}
if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) { return Err(AppError::BadRequest("flow needs at least one action block".into())); }
let mut edge_ids = std::collections::HashSet::<String>::new();
let mut connections = std::collections::HashSet::<(String, String)>::new();
for edge in &input.edges {
if edge.id.trim().is_empty() || !edge_ids.insert(edge.id.clone()) || !connections.insert((edge.from.clone(), edge.to.clone())) {
return Err(AppError::BadRequest("flow contains duplicate or empty connection IDs".into()));
}
if edge.from == edge.to || !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) {
if !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) {
return Err(AppError::BadRequest("flow contains a connection to a missing block".into()));
}
}
Ok(())
}
fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
validate_flow_draft_graph(input)?;
if input.nodes.is_empty() { return Err(AppError::BadRequest("flow needs at least one block".into())); }
let ids = input.nodes.iter().map(|node| node.id.clone()).collect::<std::collections::HashSet<_>>();
if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) { return Err(AppError::BadRequest("flow needs at least one action block".into())); }
for edge in &input.edges {
if edge.from == edge.to {
return Err(AppError::BadRequest("flow contains an invalid connection".into()));
}
let from = input.nodes.iter().find(|node| node.id == edge.from).expect("validated Flow source");
@@ -154,8 +167,7 @@ fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> {
}
}
// Reject cycles. Flow is deliberately a DAG: finite evaluation, deterministic topological order,
// and no hidden state machine semantics unless a dedicated stateful block is introduced later.
// Reject cycles for executable Flows. Drafts intentionally allow unfinished wiring and are never compiled.
let mut outgoing = std::collections::HashMap::<String, Vec<String>>::new();
for edge in &input.edges { outgoing.entry(edge.from.clone()).or_default().push(edge.to.clone()); }
fn visit(id: &str, outgoing: &std::collections::HashMap<String, Vec<String>>, temp: &mut std::collections::HashSet<String>, done: &mut std::collections::HashSet<String>) -> bool {
@@ -542,23 +554,34 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat
}
fn flow_from_input(id: String, input: FlowInput, created_at: chrono::DateTime<Utc>, revision: u64) -> crate::models::Flow {
let draft = input.draft;
crate::models::Flow {
id, name: input.name.trim().into(), enabled: input.enabled, description: input.description.trim().into(), nodes: input.nodes, edges: input.edges,
id, name: input.name.trim().into(), enabled: if draft { false } else { input.enabled }, draft,
description: input.description.trim().into(), nodes: input.nodes, edges: input.edges,
summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision, created_at, updated_at: Utc::now(),
}
}
fn prepare_draft_flow(mut flow: crate::models::Flow) -> crate::models::Flow {
flow.enabled = false;
flow.draft = true;
flow.compiled_schedule_ids.clear();
flow.compiled_automation_ids.clear();
flow.summary = format!("Draft · {} blocks", flow.nodes.len());
flow
}
async fn list_flows(State(state): State<AppState>) -> Result<Json<Vec<crate::models::Flow>>, AppError> { Ok(Json(state.db.list_flows()?)) }
async fn get_flow(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<crate::models::Flow>, AppError> { state.db.get_flow(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("flow {id}"))) }
async fn create_flow(State(state): State<AppState>, Json(input): Json<FlowInput>) -> Result<(StatusCode, Json<crate::models::Flow>), AppError> {
validate_flow_graph(&input)?;
if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }
let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await;
let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
let (flow, schedules, automations) = compile_flow(&state, flow)?;
let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? };
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
state.log("info", "flow.created", &format!("Created Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()}));
@@ -567,7 +590,7 @@ async fn create_flow(State(state): State<AppState>, Json(input): Json<FlowInput>
}
async fn update_flow(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<FlowInput>) -> Result<Json<crate::models::Flow>, AppError> {
validate_flow_graph(&input)?;
if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }
let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await;
let _schedule_guard = state.lock_schedule_operation().await;
@@ -578,7 +601,7 @@ async fn update_flow(State(state): State<AppState>, Path(id): Path<String>, Json
let old_zone_ids: std::collections::HashSet<String> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect();
let next_revision = existing.revision.saturating_add(1).max(1);
let flow = flow_from_input(id, input, existing.created_at, next_revision);
let (flow, schedules, automations) = compile_flow(&state, flow)?;
let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? };
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
let mut zone_ids = old_zone_ids; zone_ids.extend(schedules.iter().map(|s| s.zone_id.clone()));
for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; }
@@ -610,6 +633,7 @@ async fn export_flow(State(state): State<AppState>, Path(id): Path<String>) -> R
"flow": {
"name": flow.name,
"enabled": flow.enabled,
"draft": flow.draft,
"description": flow.description,
"nodes": flow.nodes,
"edges": flow.edges
@@ -624,13 +648,13 @@ async fn import_flow(State(state): State<AppState>, Json(document): Json<Value>)
let payload = document.get("flow").cloned().unwrap_or(document);
let mut input: FlowInput = serde_json::from_value(payload).map_err(|err| AppError::BadRequest(format!("invalid Flow import: {err}")))?;
input.expected_revision = None;
validate_flow_graph(&input)?;
if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }
let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await;
let _schedule_guard = state.lock_schedule_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1);
let (flow, schedules, automations) = compile_flow(&state, flow)?;
let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? };
state.db.replace_flow_outputs(&flow, &schedules, &automations)?;
for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::<std::collections::HashSet<_>>() { refresh_zone_override_boundary(&state, zone_id).await?; }
state.log("info", "flow.imported", &format!("Imported Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision}));
@@ -699,7 +723,7 @@ async fn simulate_flow(State(state): State<AppState>, Json(input): Json<FlowSimu
};
let preview_id = input.flow_id.clone().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| format!("dry-run-{}", Uuid::new_v4()));
let preview = crate::models::Flow {
id: preview_id.clone(), name: input.flow.name.trim().into(), enabled: input.flow.enabled, description: input.flow.description.trim().into(),
id: preview_id.clone(), name: input.flow.name.trim().into(), enabled: input.flow.enabled, draft: false, description: input.flow.description.trim().into(),
nodes: input.flow.nodes.clone(), edges: input.flow.edges.clone(), summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision: 0,
created_at: Utc::now(), updated_at: Utc::now(),
};
@@ -749,3 +773,50 @@ async fn flow_logs(State(state): State<AppState>, Path(id): Path<String>, Query(
}).take(limit).collect::<Vec<_>>();
Ok(Json(json!({"events": events})))
}
#[cfg(test)]
mod flow_draft_tests {
use super::*;
fn unfinished_input() -> FlowInput {
FlowInput {
name: "Unfinished".into(),
enabled: true,
draft: true,
description: String::new(),
nodes: vec![crate::models::FlowNode {
id: "condition".into(),
kind: "constant".into(),
x: 0.0,
y: 0.0,
config: json!({"value": true}),
}],
edges: vec![],
expected_revision: None,
}
}
#[test]
fn draft_graph_accepts_flow_without_action() {
let input = unfinished_input();
assert!(validate_flow_draft_graph(&input).is_ok());
assert!(validate_flow_graph(&input).is_err());
}
#[test]
fn draft_graph_still_rejects_missing_edge_endpoints() {
let mut input = unfinished_input();
input.edges.push(crate::models::FlowEdge { id: "edge".into(), from: "condition".into(), to: "missing".into() });
assert!(validate_flow_draft_graph(&input).is_err());
}
#[test]
fn draft_flow_is_forced_disabled_and_has_no_compiled_outputs() {
let flow = flow_from_input("draft-id".into(), unfinished_input(), chrono::Utc::now(), 1);
let flow = prepare_draft_flow(flow);
assert!(flow.draft);
assert!(!flow.enabled);
assert!(flow.compiled_schedule_ids.is_empty());
assert!(flow.compiled_automation_ids.is_empty());
}
}
+7
View File
@@ -202,6 +202,13 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect();
let draft_flows: std::collections::HashSet<&str> = export.flows.iter().filter(|item| item.draft).map(|item| item.id.as_str()).collect();
if export.flows.iter().any(|item| item.draft && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty()))
|| export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|| export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
{
return Err(AppError::BadRequest("import contains an executable Flow draft".into()));
}
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
+3
View File
@@ -37,6 +37,9 @@ pub struct Flow {
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
/// Work-in-progress Flow saved without executable outputs. Drafts are always disabled.
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub description: String,
#[serde(default)]
+3 -1
View File
@@ -329,7 +329,9 @@ async function api(path, options = {}) {
if (!response.ok) {
let message = tr('error.http', { status: response.status });
try { message = (await response.json()).error || message; } catch (_) { }
throw new Error(message);
const error = new Error(message);
error.status = response.status;
throw error;
}
if (response.status === 204) return null;
return response.json();
+32 -11
View File
@@ -97,17 +97,17 @@ function flowDefaultConfig(kind) {
function renderFlows() {
const host = $('#flowList'); if (!host) return;
const count = $('#flowListCount'); if (count) count.textContent = tr('flow.listCount', { count: app.flows.length });
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled ? '' : 'is-disabled'}">
<div class="list-card-head"><div><h3>${esc(flow.name)}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div><button type="button" class="zone-enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${flow.enabled ? '' : ''}</span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</button></div>
host.innerHTML = app.flows.length ? app.flows.map(flow => `<article class="list-card flow-card ${flow.enabled && !flow.draft ? '' : 'is-disabled'} ${flow.draft ? 'is-draft' : ''}">
<div class="list-card-head"><div><h3>${esc(flow.name)}${flow.draft ? ` <span class="badge flow-draft-badge">${esc(tr('flow.draft'))}</span>` : ''}</h3><p>${esc(flow.description || tr('flow.defaultDescription'))}</p></div>${flow.draft ? `<button type="button" class="zone-enable-toggle" disabled title="${esc(tr('flow.draftDisabledHint'))}"><span></span>${esc(tr('common.disabled'))}</button>` : `<button type="button" class="zone-enable-toggle ${flow.enabled ? 'active' : ''}" data-action="toggle-flow-enabled" data-id="${esc(flow.id)}" data-value="${flow.enabled ? 'false' : 'true'}" aria-label="${esc(tr(flow.enabled ? 'flow.disable' : 'flow.enable'))}" title="${esc(tr('flow.quickToggleHint'))}"><span>${flow.enabled ? '' : ''}</span>${esc(flow.enabled ? tr('common.enabled') : tr('common.disabled'))}</button>`}</div>
<div class="card-stats"><div class="card-stat"><small>${esc(tr('flow.blocks'))}</small><strong>${flow.nodes?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.schedules'))}</small><strong>${flow.compiled_schedule_ids?.length || 0}</strong></div><div class="card-stat"><small>${esc(tr('nav.automations'))}</small><strong>${flow.compiled_automation_ids?.length || 0}</strong></div></div>
<div class="card-footer"><small>${esc(tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></div>
<div class="card-footer"><small>${esc(flow.draft ? tr('flow.draftNoExecution') : tr('flow.compileCount', { schedules: flow.compiled_schedule_ids?.length || 0, automations: flow.compiled_automation_ids?.length || 0 }))}</small><div class="card-menu"><button data-action="edit-flow" data-id="${esc(flow.id)}">${esc(tr('flow.openEditor'))}</button><button class="danger" data-action="delete-flow" data-id="${esc(flow.id)}">${esc(tr('actions.delete'))}</button></div></div>
</article>`).join('') : `<div class="empty"><strong>${esc(tr('flow.emptyTitle'))}</strong>${esc(tr('flow.emptyText'))}</div>`;
}
function flowDraftFrom(flow) {
return flow ? JSON.parse(JSON.stringify(flow)) : {
id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [],
id: '', revision: 0, name: tr('flow.newDefaultName'), enabled: true, draft: false, description: '', nodes: [], edges: [], summary: '', compiled_schedule_ids: [], compiled_automation_ids: [],
};
}
@@ -326,7 +326,8 @@ function renderFlowEditor() {
const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : '';
renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); refreshFlowSharedInputCurrentValues();
const status = $('#flowCompileStatus');
status.textContent = tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 });
status.classList.toggle('flow-draft-badge', draft.draft === true);
}
function renderFlowEdges() {
@@ -527,6 +528,7 @@ function flowSourcePayload() {
return {
name: ($('#flowName')?.value || app.flowDraft.name || '').trim(),
enabled: $('#flowEnabled')?.checked !== false,
draft: app.flowDraft.draft === true,
description: app.flowDraft.description || '',
nodes: app.flowDraft.nodes || [], edges: app.flowDraft.edges || [],
};
@@ -898,16 +900,34 @@ async function openFlowLogs() {
} catch (error) { $('#flowTestResults').innerHTML = `<div class="empty compact"><strong>${esc(tr('flow.logsFailed'))}</strong><span>${esc(error.message)}</span></div>`; }
}
async function persistFlowDraft(body) {
return api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body });
}
async function applySavedFlow(saved, messageKey) {
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
app.flowDraft = flowDraftFrom(saved); app.flowDirty = false;
$('#flowEnabled').checked = saved.enabled === true;
renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr(messageKey));
}
async function saveFlow() {
if (!app.flowDraft) return;
const name = $('#flowName').value.trim(); if (!name) return toast(tr('flow.nameRequired'), true);
const body = { name, enabled: $('#flowEnabled').checked, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes, edges: app.flowDraft.edges };
const body = { name, enabled: $('#flowEnabled').checked, draft: false, description: app.flowDraft.description || '', nodes: app.flowDraft.nodes, edges: app.flowDraft.edges };
if (app.flowDraft.id) body.expected_revision = Number(app.flowDraft.revision || 0);
try {
const saved = await api(app.flowDraft.id ? `/api/flows/${encodeURIComponent(app.flowDraft.id)}` : '/api/flows', { method: app.flowDraft.id ? 'PUT' : 'POST', body });
const index = app.flows.findIndex(flow => flow.id === saved.id); if (index >= 0) app.flows[index] = saved; else app.flows.push(saved);
app.flowDraft = flowDraftFrom(saved); app.flowDirty = false; renderFlows(); renderFlowEditor(); updateBrowserUrl(`/flows/${saved.id}`, true); await loadBootstrap(); toast(tr('flow.saved'));
} catch (error) { toast(error.message, true); }
const saved = await persistFlowDraft(body);
await applySavedFlow(saved, 'flow.saved');
} catch (error) {
if (error.status !== 400) return toast(error.message, true);
const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason:error.message }));
if (!saveDraft) return toast(error.message, true);
try {
const saved = await persistFlowDraft({ ...body, enabled:false, draft:true });
await applySavedFlow(saved, 'flow.savedAsDraft');
} catch (draftError) { toast(draftError.message, true); }
}
}
async function deleteFlow(id) {
@@ -918,9 +938,10 @@ async function deleteFlow(id) {
async function toggleFlowEnabled(id, enabled) {
const flow = app.flows.find(item => item.id === id); if (!flow) return;
if (flow.draft) return toast(tr('flow.draftCannotEnable'), true);
const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return;
if (toggle) toggle.disabled = true;
const body = { name:flow.name, enabled, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) };
const body = { name:flow.name, enabled, draft:false, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) };
try {
const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method:'PUT', body });
const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved;
+5 -1
View File
@@ -6180,7 +6180,11 @@ body.flow-editor-open { overflow:hidden; }
.flow-editor-bar { min-height:76px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:10px 18px; border-bottom:1px solid var(--line); background:var(--surface); }
.flow-editor-title { display:flex; align-items:center; gap:12px; min-width:0; }
.flow-editor-title > div { display:grid; gap:2px; min-width:0; }
.flow-editor-title input { border:0; background:transparent; color:var(--text); font-weight:750; font-size:1.12rem; padding:2px 0; min-width:280px; outline:none; }
.flow-editor-title input { border:1px solid var(--line-strong); background:var(--input-bg); color:var(--text); font-weight:750; font-size:1.12rem; padding:7px 10px; min-width:280px; outline:none; border-radius:9px; box-shadow:inset 0 0 0 1px transparent; transition:border-color .15s ease,box-shadow .15s ease,background .15s ease; }
.flow-editor-title input:hover { border-color:var(--accent-border); background:var(--surface-2); }
.flow-editor-title input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); background:var(--input-bg); }
.flow-draft-badge { border-color:var(--orange); background:color-mix(in srgb,var(--orange) 14%,var(--surface)); color:var(--text); }
.flow-card.is-draft { border-style:dashed; }
.flow-editor-actions { display:flex; align-items:center; gap:10px; }
.flow-enabled { display:flex; align-items:center; gap:8px; color:var(--muted); }
.flow-editor-body { flex:1; min-height:0; display:grid; grid-template-columns:220px minmax(420px,1fr) 300px; }