From 76bbd39378d05335fdded5ca0511adc21e1fcda2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 2 Sep 2026 23:10:44 +0200 Subject: [PATCH] v0.11.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 30 +++++------ README.md | 4 +- docs/API.md | 12 ++--- docs/FLOW.md | 2 + lang/en.json | 9 +++- lang/pl.json | 9 +++- scripts/FILE_MANIFEST.sha256 | 2 +- scripts/verify_flow_logic.py | 19 ++++++- src/api/flows.rs | 99 +++++++++++++++++++++++++++++++----- src/api/settings.rs | 7 +++ src/models/flow.rs | 3 ++ web/js/core.js | 4 +- web/js/flows.js | 43 ++++++++++++---- web/styles.css | 6 ++- 16 files changed, 196 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50fb436..5dceacc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.11.1" +version = "0.11.2" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index b983276..804a0ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index ed5924e..f3b0b39 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index 933acd8..675cd53 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/API.md b/docs/API.md index 79a2c7f..a03cfb7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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-`, 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-`, 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. diff --git a/docs/FLOW.md b/docs/FLOW.md index 87d630e..1365f04 100644 --- a/docs/FLOW.md +++ b/docs/FLOW.md @@ -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-` 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 diff --git a/lang/en.json b/lang/en.json index 6f5ab4d..37cd2dd 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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." } } diff --git a/lang/pl.json b/lang/pl.json index 9376a38..af9f2a6 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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." } } diff --git a/scripts/FILE_MANIFEST.sha256 b/scripts/FILE_MANIFEST.sha256 index df259b3..8b0679c 100644 --- a/scripts/FILE_MANIFEST.sha256 +++ b/scripts/FILE_MANIFEST.sha256 @@ -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 diff --git a/scripts/verify_flow_logic.py b/scripts/verify_flow_logic.py index 6a83d76..150cb5e 100755 --- a/scripts/verify_flow_logic.py +++ b/scripts/verify_flow_logic.py @@ -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)) diff --git a/src/api/flows.rs b/src/api/flows.rs index 10d378d..f26c667 100644 --- a/src/api/flows.rs +++ b/src/api/flows.rs @@ -4,6 +4,8 @@ struct FlowInput { #[serde(default = "yes")] enabled: bool, #[serde(default)] + draft: bool, + #[serde(default)] description: String, #[serde(default)] nodes: Vec, @@ -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::::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::::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::>(); + 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::>::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>, temp: &mut std::collections::HashSet, done: &mut std::collections::HashSet) -> 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, 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) -> Result>, AppError> { Ok(Json(state.db.list_flows()?)) } async fn get_flow(State(state): State, Path(id): Path) -> Result, AppError> { state.db.get_flow(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("flow {id}"))) } async fn create_flow(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), 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::>() { 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, Json(input): Json } async fn update_flow(State(state): State, Path(id): Path, Json(input): Json) -> Result, 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, Path(id): Path, Json let old_zone_ids: std::collections::HashSet = 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, Path(id): Path) -> 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, Json(document): Json) 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::>() { 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, Json(input): Json, Path(id): Path, Query( }).take(limit).collect::>(); 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()); + } +} diff --git a/src/api/settings.rs b/src/api/settings.rs index 71ec0e6..9f4e0a4 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -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("") diff --git a/src/models/flow.rs b/src/models/flow.rs index 4d57351..0b26888 100644 --- a/src/models/flow.rs +++ b/src/models/flow.rs @@ -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)] diff --git a/web/js/core.js b/web/js/core.js index 4ec1847..603a218 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -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(); diff --git a/web/js/flows.js b/web/js/flows.js index 9c33e59..4fb1065 100644 --- a/web/js/flows.js +++ b/web/js/flows.js @@ -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 => `
-

${esc(flow.name)}

${esc(flow.description || tr('flow.defaultDescription'))}

+ host.innerHTML = app.flows.length ? app.flows.map(flow => `
+

${esc(flow.name)}${flow.draft ? ` ${esc(tr('flow.draft'))}` : ''}

${esc(flow.description || tr('flow.defaultDescription'))}

${flow.draft ? `` : ``}
${esc(tr('flow.blocks'))}${flow.nodes?.length || 0}
${esc(tr('nav.schedules'))}${flow.compiled_schedule_ids?.length || 0}
${esc(tr('nav.automations'))}${flow.compiled_automation_ids?.length || 0}
- +
`).join('') : `
${esc(tr('flow.emptyTitle'))}${esc(tr('flow.emptyText'))}
`; } 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 = `
${esc(tr('flow.logsFailed'))}${esc(error.message)}
`; } } +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; diff --git a/web/styles.css b/web/styles.css index eae5e99..280516b 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; }