From 113e5a0fe85e257aede965d47ec88adc0733c42d Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 21 Jun 2026 23:50:03 +0200 Subject: [PATCH] Implemented confi snapshots --- README.md | 12 +- TODO.md | 58 +- cmd/uopi/main.go | 2 +- docs/FUNCTIONAL_SPEC.md | 98 ++- docs/TECHNICAL_SPEC.md | 92 ++- go.mod | 3 +- go.sum | 4 + internal/api/api.go | 183 ++++++ internal/api/api_test.go | 3 +- internal/api/confmgr.go | 321 ++++++++++ internal/broker/broker.go | 25 + internal/confmgr/cue.go | 199 +++++++ internal/confmgr/cue_test.go | 80 +++ internal/confmgr/snapshot.go | 129 ++++ internal/confmgr/snapshot_test.go | 83 +++ internal/confmgr/store.go | 142 ++++- internal/confmgr/store_test.go | 70 +++ internal/controllogic/config_node_test.go | 234 ++++++++ internal/controllogic/engine.go | 278 ++++++++- internal/controllogic/model.go | 2 + internal/controllogic/store.go | 54 +- internal/controllogic/versions.go | 142 +++++ internal/controllogic/versions_test.go | 70 +++ internal/datasource/synthetic/definition.go | 6 + internal/datasource/synthetic/synthetic.go | 13 + internal/datasource/synthetic/versions.go | 174 ++++++ .../datasource/synthetic/versions_test.go | 87 +++ web/src/Canvas.tsx | 2 + web/src/ConfigManager.tsx | 562 +++++++++++++++--- web/src/ControlLogicEditor.tsx | 198 +++++- web/src/CueEditor.tsx | 259 ++++++++ web/src/EditCanvas.tsx | 1 + web/src/EditMode.tsx | 152 ++--- web/src/LogicEditor.tsx | 167 ++++++ web/src/PropertiesPane.tsx | 44 ++ web/src/SyntheticGraphEditor.tsx | 72 +++ web/src/VersionHistory.tsx | 227 +++++++ web/src/lib/linediff.ts | 99 +++ web/src/lib/logic.ts | 167 ++++++ web/src/lib/types.ts | 7 +- web/src/styles.css | 387 ++++++++++++ web/src/widgets/ConfigSelect.tsx | 80 +++ workspace/data/audit.db | Bin 24576 -> 24576 bytes .../data/configs/instances/new-instance.json | 13 + .../configs/instances/new-instance.v1.json | 8 + .../configs/instances/new-instance.v2.json | 12 + .../data/configs/sets/new-config-set.json | 46 ++ .../data/configs/sets/new-config-set.v1.json | 7 + .../data/configs/sets/new-config-set.v2.json | 25 + ...rature_fork_1782058579992-1fd31660.v1.json | 61 ++ workspace/softioc.log | 2 - 51 files changed, 4921 insertions(+), 241 deletions(-) create mode 100644 internal/confmgr/cue.go create mode 100644 internal/confmgr/cue_test.go create mode 100644 internal/confmgr/snapshot.go create mode 100644 internal/confmgr/snapshot_test.go create mode 100644 internal/controllogic/config_node_test.go create mode 100644 internal/controllogic/versions.go create mode 100644 internal/controllogic/versions_test.go create mode 100644 internal/datasource/synthetic/versions.go create mode 100644 internal/datasource/synthetic/versions_test.go create mode 100644 web/src/CueEditor.tsx create mode 100644 web/src/VersionHistory.tsx create mode 100644 web/src/lib/linediff.ts create mode 100644 web/src/widgets/ConfigSelect.tsx create mode 100644 workspace/data/configs/instances/new-instance.json create mode 100644 workspace/data/configs/instances/new-instance.v1.json create mode 100644 workspace/data/configs/instances/new-instance.v2.json create mode 100644 workspace/data/configs/sets/new-config-set.json create mode 100644 workspace/data/configs/sets/new-config-set.v1.json create mode 100644 workspace/data/configs/sets/new-config-set.v2.json create mode 100644 workspace/data/synthetic_versions/flow_temperature_fork_1782058579992-1fd31660.v1.json diff --git a/README.md b/README.md index 6b981a8..1564bff 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an | **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport | | **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode | | **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks | +| **Configuration manager** | Versioned configuration **sets** (typed signal schemas, grouped) and **instances** (values) with validation, array/CSV editing, apply-to-signals, and JSON import/export | +| **Version history & diff** | Git-style history for panels, synthetic signals, control logic and config sets/instances: view, fork, promote any revision, with unified / side-by-side diff | | **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic | | **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI | | **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes | @@ -138,12 +140,20 @@ The REST API is available under `/api/v1`. Key endpoints: | `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface | | `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders | | `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules | -| `GET` | `/api/v1/interfaces/{id}/versions` | List saved versions of a panel | | `GET/POST` | `/api/v1/folders` | List or create panel folders | | `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder | | `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions | | `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs | | `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph | +| `GET/POST` | `/api/v1/config/sets` | List or create configuration sets (schemas) | +| `GET/PUT/DELETE` | `/api/v1/config/sets/{id}` | Read, update, or delete a config set | +| `GET/POST` | `/api/v1/config/instances` | List or create configuration instances (values) | +| `GET/PUT/DELETE` | `/api/v1/config/instances/{id}` | Read, update, or delete a config instance | +| `POST` | `/api/v1/config/instances/{id}/apply` | Write an instance's values to their target signals | +| `GET` | `/api/v1/config/{sets\|instances}/diff` | Structural diff between two revisions | +| `GET` | `/api/v1/{interfaces\|synthetic\|controllogic\|config/sets\|config/instances}/{id}/versions` | List revisions; `…/{version}` fetches one | +| `POST` | `…/{id}/versions/{v}/promote` | Promote a revision to current | +| `POST` | `…/{id}/versions/{v}/fork` | Fork a revision into a new document | | `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission | | `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) | | `GET` | `/metrics` | Prometheus-format server metrics | diff --git a/TODO.md b/TODO.md index 255245a..eaa4f99 100644 --- a/TODO.md +++ b/TODO.md @@ -12,7 +12,13 @@ - user can compare two instances: show unified diff or side by side diff - etc... - [x] user can apply and save configuration instances from manager - - [ ] in logic editor and control loop add nodes to read/write/create/apply config instances + - [x] **instance snapshot**: create a new instance from the current live value of all of a set's target signals (optional label, else auto name) — exposed in the config editor (⎙ Snapshot), control loop (`action.config.snapshot`) and logic editor (`action.config.snapshot`) + - [x] **live diff**: compare a stored instance against current signal values ("Diff vs current" button → `/config/instances/{id}/livediff`) + - [x] git-style versioning for config sets and instances: fork any revision, click to view, promote to current, with unified / side-by-side diff (shared `VersionTree`) + - [x] in logic editor and control loop add nodes to read/write/create/apply config instances + - [x] **apply** and **read** config-instance nodes in both control logic (server Go) and panel logic (client TS) + - [x] **create / write** nodes (mutate versioned instances from automation) in both engines + - [x] **Config Selector** panel widget: operator picks an instance of a chosen set (optionally a subset) from a combo, writing its id to a panel-local string variable; apply/read/write panel-logic nodes can read their instance dynamically from that variable ("From variable" source) - [x] support for all supported types (number, bools, enums, arrays, string etc) - [x] ux elements for config set - [x] the configuration editor should automatically get type and info from signal when possible (type is read-only, auto-derived from the bound signal) @@ -25,17 +31,20 @@ - for arrays: import csv option, display points as mini plot (+ open dialog plot with more info), manual enter points via table like interface - automatically fill with default or existing values (when forking an existing instance) - explicity show errors/missing info and give additional info on hover - - [ ] add advanced validation / transformation framework to configurations using custom CUE rules: - - backend should run validation / transformation rules - - user can create/edit/delete/compare rules from webui: - - integrate syntax highlight - - integrate autocomplete for signals names and cue grammars - - integrate cue lsp - - when validation fail error should be propagated to user in the webui - - all action should be tracked via history management and audit + - [x] add advanced validation / transformation framework to configurations using custom CUE rules: + - [x] backend runs validation / transformation rules (real CUE via `cuelang.org/go`; `internal/confmgr/cue.go`). Rules are a third versioned `Kind` bound to a set; evaluated on instance create/update — violations block the save, concrete derivations transform & persist the values + - [x] user can create/edit/delete/compare rules from webui (Rules tab in ConfigManager; git-style versioning + side-by-side/unified source diff) + - [x] integrate syntax highlight (hand-rolled `CueEditor.tsx`, mirroring `LuaEditor`) + - [x] integrate autocomplete for signals names and cue grammars (param keys + target signals + CUE keywords/types; Ctrl+Space) + - [ ] ~~integrate cue lsp~~ — descoped: a separate language-server process does not fit the no-npm / single portable-binary architecture + - [x] when validation fails the error is propagated to the user (live `/config/rules/check` panel while editing; save returns the structured violation) + - [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`) - [ ] Improve UX: - - [x] Synthetic editor: - - [x] color code the node link by type + - [ ] Synthetic editor: + - [ ] color code the node link by type + - [ ] edges color depends on type (e.g. float) including arrays + - [ ] input / outputs are color coded (if can accept many, gray, otherwise specific color, can be dynamic) + - [ ] can not connect input / output that are not compatible - [x] hover on a block in error should show the reason - [x] add proper array functionality - [x] add shortcut to add Signals (S) and nodes (N) with HUD @@ -44,7 +53,12 @@ - [ ] add widgets such as toggle switch, table and other industrial hmi widgets - [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc - [ ] plot pane: - - add toolbar + - [ ] add toolbar + - [ ] add time window selector to toolbar + - [ ] plot x-axis synchronised (option to unlink in toolbar) + - [ ] cursors and measuraments + - [ ] pause/resume + - [ ] save screenshot - [ ] clean ui: - create small statusbar where connection widget and other status related info will be placed - group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar @@ -54,16 +68,16 @@ - array functions should work with new local array - [ ] Control loop: - add full support to server side array values -- [ ] Implement git style versioning for: synthetic variable, panels, control logic: - - possibility to fork any version - - click to view the version - - possibility to view graphical diff between versions (side by side or unified diff) - - simple slick versioning pane: - - vertical tree like - - each version represented by a circle - - active (the one currently view/edited) version has circle bigger then rest - - selected (the one that will be executed/showed by user) version has circle full, not active only border - - unsaved / new version appear with connection line dashed +- [x] Implement git style versioning for: synthetic variable, panels, control logic: + - [x] possibility to fork any version + - [x] click to view the version + - [x] possibility to view graphical diff between versions (side by side or unified diff) + - [x] simple slick versioning pane: + - [x] vertical tree like + - [x] each version represented by a circle + - [x] active (the one currently view/edited) version has circle bigger then rest + - [x] selected (the one that will be executed/showed by user) version has circle full, not active only border + - [x] unsaved / new version appear with connection line dashed - [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc - [ ] Implement new datasources: - [ ] Finalize alarm service diff --git a/cmd/uopi/main.go b/cmd/uopi/main.go index 4bbeba8..3a0e8d0 100644 --- a/cmd/uopi/main.go +++ b/cmd/uopi/main.go @@ -192,7 +192,7 @@ func main() { log.Error("failed to open control logic store", "err", err) os.Exit(1) } - ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, recorder, log) + ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, cfgStore, recorder, log) // Dialog hub: control-logic action.dialog nodes push notifications/inputs to // connected clients (filtered by user/group) and route input responses back diff --git a/docs/FUNCTIONAL_SPEC.md b/docs/FUNCTIONAL_SPEC.md index 643bed6..5dcc78d 100644 --- a/docs/FUNCTIONAL_SPEC.md +++ b/docs/FUNCTIONAL_SPEC.md @@ -279,7 +279,7 @@ copy/paste. |----------|-------| | Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle | | Logic | AND gate, If (then/else), Loop (count or while) | -| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays | +| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays; Apply config / Read config / Write config / Create config / Snapshot config (see §11) | | Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) | **System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds @@ -296,6 +296,12 @@ managed through the REST API. - Triggers include *cron* schedules and signal *alarm*/threshold conditions. - A **Lua** block provides custom logic; results are written back to signals. +- **Apply / Read / Write / Create config** action nodes drive the configuration manager + (§11): *Apply config* writes every value of a chosen instance to its bound signals + (audited); *Read config* reads one parameter's value into a target signal or variable; + *Write config* stores a value into a parameter (creating a new instance revision); *Create + config* makes a new instance for a set, optionally seeded from another instance. Both + mutating nodes are audited. - Each graph can be enabled/disabled independently; saving reloads the engine live. - Editing is gated by the logic-edit restriction (§2). @@ -305,7 +311,7 @@ managed through the REST API. - Interfaces are saved to the server in XML format and are available to all connected clients. - Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON). -- Saved versions are retained; a panel's version history can be listed, tagged and promoted. +- Saved versions are retained; a panel's version history can be listed, tagged, viewed, promoted, forked and diffed — the same git-style versioning shared by all versioned documents (§12). - Export/Import allows local file exchange of XML files. - The XML schema records: interface kind (panel/plot) and split layout, widget type, position, size, signal bindings, all property values, local variables, and panel logic. @@ -345,7 +351,93 @@ When the server has archive access configured: --- -## 11. Non-functional Requirements +## 11. Configuration Manager + +The **Configuration manager** (opened from the View-mode toolbar) manages three kinds of +versioned documents, on **Sets**, **Instances** and **Rules** tabs, in a full-screen modal. + +**Configuration sets** are schemas: an ordered list of typed parameters, each binding a +target signal. Parameters can be organised into **groups** and **subgroups** via a +drag-and-drop tree. +- A parameter's **type** (number, integer, boolean, string, enum, array/waveform) and its + unit/range/enum values are auto-derived from the bound signal's metadata when available; + the type is read-only. +- Each parameter may declare a **default**, a **mandatory** flag, and **min/max** / + **enum** constraints. +- Sets can be **exported / imported** as JSON. + +**Configuration instances** assign concrete values to a chosen set's parameters. +- The editor renders the right control per type (number input, enum/boolean combo, and a + waveform editor with live sparkline, table editing, CSV import and a pop-out plot for + arrays); empty fields fall back to the parameter default. +- Validation mirrors the backend (required-but-unset, out-of-range, wrong type, bad enum) + and is surfaced inline with hover detail. +- **Apply** writes every value to its target signal and reports a per-parameter + applied / failed / skipped result. +- **Snapshot** (⎙ in the Instances tab) captures the *current live value* of every target + signal of a chosen set into a new instance — an optional label, otherwise an auto name. This + records "what the hardware holds right now" as a reusable configuration. +- **Diff vs current** compares a stored instance (resolved with defaults) against the current + live signal values, so an operator can see how the saved configuration differs from what the + system currently has. + +The Sets and Instances tabs support the shared version history (§12): view, fork and promote +revisions, plus a **structural diff** between any two revisions (per-parameter added / removed +/ changed / unchanged). + +**Validation / transformation rules** (Rules tab) attach **CUE** logic to a set. Each rule is +CUE source describing constraints and/or derivations over the parameter values: a field like +`voltage: >=0 & <=24` validates a value, while a concrete derivation like +`power: voltage * current` computes one. When an instance of the bound set is saved, every +rule runs — a failed constraint **blocks the save** and surfaces the violation, and derived +values are written into the stored instance. The rule editor is a CUE-aware code editor with +syntax highlighting and autocomplete (parameter keys, target signal names, and CUE +keywords/types via Ctrl+Space); a live panel re-evaluates the rule against the set's default +values on every keystroke, showing compile errors, violations, or the derived values. Rules +are versioned like the other documents, with a side-by-side / unified source diff, and every +create / edit / delete is audited. (A full CUE language server is intentionally out of scope: +it does not fit the no-dependency, single portable-binary design.) + +**Automation:** both the panel-logic (§6) and control-logic (§7) editors expose **Apply +config**, **Read config**, **Write config**, **Create config** and **Snapshot config** action +nodes. *Apply config* applies a chosen instance exactly like the Apply button; *Read config* +reads a single numeric parameter into a target signal or variable; *Write config* stores a +value into a parameter, creating a new instance revision; *Create config* makes a new instance +for a set, optionally seeded from another instance; *Snapshot config* captures every target +signal's current live value of a set into a new instance (like the ⎙ Snapshot button). The +mutating nodes are audited. + +**Config Selector widget:** a panel widget that lets an operator pick which configuration a +flow operates on at run time. It lists the instances of a chosen set — all of them or a +defined subset — in a combo and writes the selected instance id to a panel-local string +variable. The panel-logic Apply / Read / Write nodes can take their instance "From variable" +(reading that id) instead of a fixed instance, so the operator's choice drives the flow. +Control-logic nodes use fixed instances only (their variables are numeric). + +--- + +## 12. Version History & Diff + +All versioned documents — panels, synthetic signals, control-logic graphs, and +configuration sets/instances — share one git-style version model and a common history +pane: + +- A **vertical version tree** lists every revision (one node each); the **current** + (executed) revision is filled, the **viewed** revision is enlarged, and unsaved edits show + a dashed connector. +- **View** loads any past revision read-only into the editor — viewing alone is not an edit + and does not mark the document dirty; saving from a viewed revision creates a new revision + on top (history is never destroyed). +- **Fork** copies a revision into a brand-new document (version reset to 1). +- **Promote** re-saves an older revision as a new current revision. +- **Diff** compares two revisions, defaulting to *current-vs-selected*, shown either + **unified** or **side-by-side**. Panels, synthetic and control-logic use a generic line + diff of the serialized document; configuration sets/instances use a richer per-parameter + structural diff. + +--- + +## 13. Non-functional Requirements | Requirement | Target | |-------------|--------| diff --git a/docs/TECHNICAL_SPEC.md b/docs/TECHNICAL_SPEC.md index 4607651..15c251a 100644 --- a/docs/TECHNICAL_SPEC.md +++ b/docs/TECHNICAL_SPEC.md @@ -198,10 +198,6 @@ Base path: `/api/v1` | POST | `/interfaces/reorder` | Reorder panels / move between folders | | GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface | | POST | `/interfaces/{id}/clone` | Clone an interface | -| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one | -| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version | -| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current | -| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel | | GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules | | GET, POST | `/folders` | List or create panel folders | | PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder | @@ -211,10 +207,33 @@ Base path: `/api/v1` | GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition | | GET, POST | `/controllogic` | List or create server-side control-logic graphs | | GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph | +| GET, POST | `/config/sets` | List or create configuration sets (schemas) | +| GET, PUT, DELETE| `/config/sets/{id}` | Read, update, or delete a config set | +| GET, POST | `/config/instances` | List or create configuration instances (values) | +| GET, PUT, DELETE| `/config/instances/{id}` | Read, update, or delete a config instance | +| POST | `/config/instances/{id}/apply` | Write an instance's values to their target signals | +| POST | `/config/instances/{id}/validate` | Run the set's CUE rules over the stored values; returns a structured `RuleResult` (no save) | +| GET | `/config/instances/{id}/livediff` | Diff the stored instance (resolved with defaults) against the current live signal values | +| POST | `/config/sets/{id}/snapshot` | Capture every target signal's current live value into a new instance (body: optional `{name}`) | +| GET | `/config/{sets\|instances}/diff` | Structural diff between two revisions (`a,av,b,bv`)| +| GET, POST | `/config/rules` | List or create CUE validation/transformation rules | +| GET, PUT, DELETE| `/config/rules/{id}` | Read, update, or delete a rule | +| POST | `/config/rules/check` | Compile an (unsaved) CUE source and evaluate it against sample values — powers the live editor | -Mutating requests are gated by the access middleware (§8): global level for writes, +**Versioning (shared).** Interfaces, synthetic signals, control-logic graphs and config +sets/instances all expose the same revision endpoints under their `{base}`: + +| Method | Path | Description | +| ------ | ---- | ----------- | +| GET | `{base}/{id}/versions` | List revisions; `…/{version}` fetches one | +| POST | `{base}/{id}/versions/{v}/promote` | Promote a revision to current | +| POST | `{base}/{id}/versions/{v}/fork` | Fork a revision into a new document | +| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a panel version | + +Mutating requests are gated by the access middleware (§3.10): global level for writes, per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic -endpoints and for any change to a panel's `` block. +endpoints and for any change to a panel's `` block. Config-set/instance +promote/fork are gated by the same write policy. ### 3.5 EPICS Data Source @@ -318,6 +337,27 @@ and writes results back to signals. Graphs are persisted by a store and managed `/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs can be individually enabled/disabled. +The engine also holds a `*confmgr.Store` (injected via `NewEngine`) for five config action +nodes: `action.config.apply` resolves an instance + its set and runs `confmgr.Apply` with a +broker-backed, audited write closure; `action.config.read` resolves a single parameter value +(`ConfigInstance.Resolve`), coerces it to float64 and writes it to the node's target; +`action.config.write` evaluates an expression and stores the (type-coerced, via +`coerceParamValue`) value into a parameter, creating a new instance revision; +`action.config.create` creates a new instance for a set, optionally seeding its values from +another instance; and `action.config.snapshot` reads every target signal's current live value +(via the one-shot `Broker.ReadNow`, type-coerced by `confmgr.Snapshot`) and stores them as a +new instance. All mutating nodes are audited. The panel-logic engine +(`web/src/lib/logic.ts`) mirrors all five client-side via the REST endpoints +(`/config/instances/{id}/apply`, GET/PUT `/config/instances/{id}`, POST `/config/instances`, +POST `/config/sets/{id}/snapshot`). +Panel-logic apply/read/write nodes additionally support an `instanceSource: 'var'` mode that +reads the target instance id (a string) from a panel-local variable rather than a fixed id — +fed by the **Config Selector** widget (`web/src/widgets/ConfigSelect.tsx`), which lists a +set's instances in a combo and writes the chosen id to that variable. Control-logic nodes use +fixed ids only (its variables are numeric, instance ids are strings). To let the selector +filter instances by set without a GET per instance, `confmgr.Store.List` now returns each +instance's `setId` in its `Meta`. + ### 3.10 Access Control Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity @@ -329,6 +369,46 @@ the optional `server.logic_editors` allowlist over control-logic endpoints and o change to a panel's `` block; it is surfaced to the frontend through `/api/v1/me` (`canEditLogic`) so the UI can hide logic-editing affordances. +### 3.11 Configuration Manager + +The configuration manager is `internal/confmgr`: a two-tier model of **sets** (typed +parameter schemas binding target signals) and **instances** (values for a chosen set). +`model.go` defines `ConfigSet`/`Parameter`/`ConfigInstance`; `apply.go` validates an +instance against its set (`checkValue`) and writes each value to its target signal, +returning a per-parameter apply report; `diff.go` produces the structural per-parameter +diff used by the `/config/{sets,instances}/diff` endpoints. + +`store.go` persists each object as `configs/{sets,instances,rules}/{id}.json`, with superseded +revisions backed up as `{id}.vN.json` alongside — the same git-style scheme as panels and +synthetic/control-logic, so `Versions`/`GetVersion`/`Promote`/`Fork` behave identically. +`Delete` is non-destructive: the object and all its backups are moved to a timestamped +`trash/configs/…` folder. + +**CUE rules (`cue.go`, `KindRule`).** A third versioned object type carries a CUE source +(`cuelang.org/go`) bound to a set via `SetID`. `EvaluateRule` compiles the source, unifies it +with the instance values (`ctx.Encode`), and validates with `cue.Concrete(true)`: regular +fields whose key matches a parameter constrain its value (failures become `RuleViolation`s), +while concrete derivations whose value differs from the input are reported (and persisted) as +**transformations**; hidden fields `_x` and definitions `#X` are helpers, excluded from both. +`CreateInstance`/`UpdateInstance` call `applyRules` after the structural `ValidateAgainst`: +all rules bound to the set are run in order (`evaluateRules`), a violation aborts the save as +a `*RuleError`, and successful transformations are merged back into the stored values (set +parameters only). `ValidateInstanceRules` is the read-only counterpart behind +`/config/instances/{id}/validate`; `EvaluateRule` is exposed directly via +`/config/rules/check` for the live editor. + +### 3.12 Document Versioning + +Versioning is implemented per storage layer but follows one shared contract: the live +revision lives in the primary file; each save backs up the previous revision as +`{id}.v{N}.{ext}`; `VersionMeta{Version,Name,Tag,Current,SavedAt}` describes each. `Promote` +re-saves an older revision on top (creating a new current revision, never destroying +history); `Fork` writes the revision out under a fresh id with its version reset to 1. The +frontend `web/src/VersionHistory.tsx` (`VersionTree` + `DiffViewer`) consumes these +generically; line diffs are computed client-side in `web/src/lib/linediff.ts`, while +config sets/instances use the backend structural diff instead. Config **rules** reuse the +generic client-side `DiffViewer` (line diff over the source). + --- ## 4. Frontend Architecture diff --git a/go.mod b/go.mod index 189ed4a..960acd2 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/uopi/uopi go 1.26.2 require ( + cuelang.org/go v0.16.1 github.com/BurntSushi/toml v1.6.0 github.com/coder/websocket v1.8.14 github.com/evanw/esbuild v0.28.0 @@ -16,7 +17,7 @@ require ( github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.42.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 60c19a5..bc560b9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE= +cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= @@ -23,6 +25,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= diff --git a/internal/api/api.go b/internal/api/api.go index 72de759..4028b00 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "fmt" "io" "log/slog" "net" @@ -105,6 +106,11 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic) mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic) mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic) + // Synthetic signal git-style versioning (list / view / promote / fork). + mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions) + mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion) + mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/promote", h.promoteSyntheticVersion) + mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/fork", h.forkSyntheticVersion) // Server-side control logic CRUD (mutations are write-gated by the access // middleware; each mutation reloads the running engine). mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic) @@ -112,6 +118,11 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic) mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic) mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic) + // Control-logic git-style versioning (list / view / promote / fork). + mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions", h.listControlLogicVersions) + mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions/{version}", h.getControlLogicVersion) + mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/promote", h.promoteControlLogicVersion) + mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/fork", h.forkControlLogicVersion) // Configuration manager — config sets (schemas) and instances (values), // both versioned git-style. Mutations are write-gated by the access // middleware; "apply" writes each value to its target signal. @@ -124,6 +135,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions/{version}", h.getConfigSetVersion) mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/promote", h.promoteConfigSet) mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/fork", h.forkConfigSet) + mux.HandleFunc("POST "+prefix+"/config/sets/{id}/snapshot", h.snapshotConfigSet) mux.HandleFunc("GET "+prefix+"/config/sets/diff", h.diffConfigSets) mux.HandleFunc("GET "+prefix+"/config/instances", h.listConfigInstances) mux.HandleFunc("POST "+prefix+"/config/instances", h.createConfigInstance) @@ -135,7 +147,22 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) { mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/promote", h.promoteConfigInstance) mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/fork", h.forkConfigInstance) mux.HandleFunc("POST "+prefix+"/config/instances/{id}/apply", h.applyConfigInstance) + mux.HandleFunc("POST "+prefix+"/config/instances/{id}/validate", h.validateConfigInstance) + mux.HandleFunc("GET "+prefix+"/config/instances/{id}/livediff", h.diffConfigInstanceLive) mux.HandleFunc("GET "+prefix+"/config/instances/diff", h.diffConfigInstances) + // Config rules — CUE validation/transformation logic bound to a set, run + // when instances of that set are saved. Versioned git-style; "check" + // evaluates an unsaved source for the live editor. + mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules) + mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule) + mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule) + mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule) + mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule) + mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule) + mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions", h.listConfigRuleVersions) + mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions/{version}", h.getConfigRuleVersion) + mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/promote", h.promoteConfigRule) + mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/fork", h.forkConfigRule) } // ── /me ───────────────────────────────────────────────────────────────────── @@ -1194,6 +1221,80 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) { + if h.synthetic == nil { + jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") + return + } + name := r.PathValue("name") + versions, err := h.synthetic.Versions(name) + if err != nil { + jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name) + return + } + jsonOK(w, versions) +} + +func (h *Handler) getSyntheticVersion(w http.ResponseWriter, r *http.Request) { + if h.synthetic == nil { + jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") + return + } + name := r.PathValue("name") + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + def, err := h.synthetic.GetVersion(name, version) + if err != nil { + jsonError(w, http.StatusNotFound, "version not found") + return + } + jsonOK(w, def) +} + +func (h *Handler) promoteSyntheticVersion(w http.ResponseWriter, r *http.Request) { + if h.synthetic == nil { + jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") + return + } + name := r.PathValue("name") + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + def, err := h.synthetic.PromoteVersion(name, version) + if err != nil { + jsonError(w, http.StatusNotFound, "version not found") + return + } + h.recordMutation(r, "synthetic.promote", fmt.Sprintf("%s v%d", name, version)) + jsonOK(w, def) +} + +func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) { + if h.synthetic == nil { + jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled") + return + } + name := r.PathValue("name") + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + def, err := h.synthetic.ForkVersion(name, version) + if err != nil { + jsonError(w, http.StatusNotFound, "version not found") + return + } + h.recordMutation(r, "synthetic.fork", fmt.Sprintf("%s v%d -> %s", name, version, def.Name)) + w.WriteHeader(http.StatusCreated) + jsonOK(w, map[string]string{"id": def.Name}) +} + // ── Control logic ────────────────────────────────────────────────────────────── func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) { @@ -1294,6 +1395,88 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func (h *Handler) listControlLogicVersions(w http.ResponseWriter, r *http.Request) { + if h.ctrlLogic == nil { + jsonError(w, http.StatusServiceUnavailable, "control logic not enabled") + return + } + versions, err := h.ctrlLogic.Versions(r.PathValue("id")) + if err != nil { + jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id")) + return + } + jsonOK(w, versions) +} + +func (h *Handler) getControlLogicVersion(w http.ResponseWriter, r *http.Request) { + if h.ctrlLogic == nil { + jsonError(w, http.StatusServiceUnavailable, "control logic not enabled") + return + } + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + g, err := h.ctrlLogic.GetVersion(r.PathValue("id"), version) + if err != nil { + jsonError(w, http.StatusNotFound, "version not found") + return + } + jsonOK(w, g) +} + +func (h *Handler) promoteControlLogicVersion(w http.ResponseWriter, r *http.Request) { + if h.ctrlLogic == nil { + jsonError(w, http.StatusServiceUnavailable, "control logic not enabled") + return + } + if !h.policy.CanEditLogic(caller(r)) { + jsonError(w, http.StatusForbidden, "you are not permitted to edit logic") + return + } + id := r.PathValue("id") + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + g, err := h.ctrlLogic.Promote(id, version) + if err != nil { + jsonError(w, http.StatusNotFound, "version not found") + return + } + h.ctrlEngine.Reload() + h.recordMutation(r, "controllogic.promote", fmt.Sprintf("%s v%d", id, version)) + jsonOK(w, g) +} + +func (h *Handler) forkControlLogicVersion(w http.ResponseWriter, r *http.Request) { + if h.ctrlLogic == nil { + jsonError(w, http.StatusServiceUnavailable, "control logic not enabled") + return + } + if !h.policy.CanEditLogic(caller(r)) { + jsonError(w, http.StatusForbidden, "you are not permitted to edit logic") + return + } + id := r.PathValue("id") + version, err := strconv.Atoi(r.PathValue("version")) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version")) + return + } + g, err := h.ctrlLogic.Fork(id, version) + if err != nil { + jsonError(w, http.StatusNotFound, "version not found") + return + } + h.ctrlEngine.Reload() + h.recordMutation(r, "controllogic.fork", fmt.Sprintf("%s v%d -> %s", id, version, g.ID)) + w.WriteHeader(http.StatusCreated) + jsonOK(w, map[string]string{"id": g.ID}) +} + // ── Helpers ─────────────────────────────────────────────────────────────────── // extractLogicBlock returns the verbatim section of an diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ed55274..7c045ec 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -52,13 +52,14 @@ func setup(t *testing.T) (*httptest.Server, func()) { if err != nil { t.Fatal("controllogic.NewStore:", err) } - clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), log) cfgStore, err := confmgr.New(dir) if err != nil { t.Fatal("confmgr.New:", err) } + clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log) + mux := http.NewServeMux() api.New(brk, nil, store, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", log).Register(mux, "/api/v1") diff --git a/internal/api/confmgr.go b/internal/api/confmgr.go index 80542f6..ee59fb6 100644 --- a/internal/api/confmgr.go +++ b/internal/api/confmgr.go @@ -6,8 +6,10 @@ import ( "errors" "net/http" "strconv" + "sync" "time" + "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/confmgr" ) @@ -444,3 +446,322 @@ func (h *Handler) resolveInstance(id, version string) (confmgr.ConfigInstance, e } return h.cfg.GetInstanceVersion(id, v) } + +// validateConfigInstance evaluates the CUE rules bound to an instance's set +// against its stored values, without persisting anything. The structured +// RuleResult (violations + any transformed values) lets the UI surface +// per-parameter failures. +func (h *Handler) validateConfigInstance(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + inst, err := h.cfg.GetInstance(r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + res, err := h.cfg.ValidateInstanceRules(inst) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonOK(w, res) +} + +// ── config snapshot / live diff ───────────────────────────────────────────── + +// readResult is the per-signal outcome of a parallel live read. +type readResult struct { + val any + err error +} + +// readSetSignals reads the current value of every distinct target signal of a +// set in parallel (deduplicated by ds+signal), bounded by ctx. The returned map +// is keyed by {ds, signal}. +func (h *Handler) readSetSignals(ctx context.Context, set confmgr.ConfigSet) map[[2]string]readResult { + jobs := make(map[[2]string]struct{}, len(set.Parameters)) + for _, p := range set.Parameters { + jobs[[2]string{p.DS, p.Signal}] = struct{}{} + } + results := make(map[[2]string]readResult, len(jobs)) + var mu sync.Mutex + var wg sync.WaitGroup + for k := range jobs { + wg.Add(1) + go func(ds, signal string) { + defer wg.Done() + v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal}) + mu.Lock() + results[[2]string{ds, signal}] = readResult{val: v.Data, err: err} + mu.Unlock() + }(k[0], k[1]) + } + wg.Wait() + return results +} + +// snapshotReader builds a confmgr.ReadFunc backed by a pre-read results map. +func snapshotReader(results map[[2]string]readResult) confmgr.ReadFunc { + return func(ds, signal string) (any, error) { + r, ok := results[[2]string{ds, signal}] + if !ok { + return nil, errors.New("signal not read") + } + return r.val, r.err + } +} + +// snapshotConfigSet captures the current value of every target signal of a set +// and stores them as a new config instance. Body: optional {name}. Returns the +// created instance plus the per-parameter capture result. +func (h *Handler) snapshotConfigSet(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + set, err := h.cfg.GetSet(r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + var body struct { + Name string `json:"name"` + } + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) // body is optional + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + results := h.readSetSignals(ctx, set) + snap := confmgr.Snapshot(set, snapshotReader(results)) + + name := body.Name + if name == "" { + name = set.Name + " snapshot " + time.Now().Format("2006-01-02 15:04:05") + } + inst := confmgr.ConfigInstance{ + Name: name, + SetID: set.ID, + Owner: caller(r), + Values: snap.Values, + } + out, err := h.cfg.CreateInstance(inst, r.URL.Query().Get("tag")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.instance.snapshot", out.ID+" "+out.Name+" captured="+strconv.Itoa(snap.Captured)+" failed="+strconv.Itoa(snap.Failed)) + w.WriteHeader(http.StatusCreated) + jsonOK(w, struct { + Instance confmgr.ConfigInstance `json:"instance"` + Snapshot confmgr.SnapshotResult `json:"snapshot"` + }{out, snap}) +} + +// diffConfigInstanceLive compares a stored instance against the current live +// values of its set's target signals, so an operator can see how the saved +// config differs from what the hardware currently holds. The stored side uses +// resolved values (instance value or parameter default) so default-backed +// parameters are not reported as spurious additions. +func (h *Handler) diffConfigInstanceLive(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + inst, err := h.cfg.GetInstance(r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + set, err := h.cfg.SetForInstance(inst) + if err != nil { + jsonError(w, configStatus(err), "load set: "+err.Error()) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + results := h.readSetSignals(ctx, set) + snap := confmgr.Snapshot(set, snapshotReader(results)) + + left := confmgr.ConfigInstance{ID: inst.ID, Name: inst.Name, Values: map[string]any{}} + for _, p := range set.Parameters { + if v, ok := inst.Resolve(p); ok { + left.Values[p.Key] = v + } + } + current := confmgr.ConfigInstance{Name: "current", Values: snap.Values} + jsonOK(w, confmgr.DiffInstances(left, current)) +} + +// ── config rules (CUE validation/transformation) ──────────────────────────── + +func (h *Handler) listConfigRules(w http.ResponseWriter, _ *http.Request) { + if !h.configEnabled(w) { + return + } + rules, err := h.cfg.List(confmgr.KindRule) + if err != nil { + jsonError(w, http.StatusInternalServerError, err.Error()) + return + } + jsonOK(w, rules) +} + +func (h *Handler) getConfigRule(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + rule, err := h.cfg.GetRule(r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, rule) +} + +func (h *Handler) createConfigRule(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + var rule confmgr.ConfigRule + if err := json.NewDecoder(r.Body).Decode(&rule); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + rule.ID = "" + rule.Owner = caller(r) + out, err := h.cfg.CreateRule(rule, r.URL.Query().Get("tag")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.rule.create", out.ID+" "+out.Name) + w.WriteHeader(http.StatusCreated) + jsonOK(w, out) +} + +func (h *Handler) updateConfigRule(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + id := r.PathValue("id") + var rule confmgr.ConfigRule + if err := json.NewDecoder(r.Body).Decode(&rule); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + out, err := h.cfg.UpdateRule(id, rule, r.URL.Query().Get("tag")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.rule.update", out.ID+" v"+strconv.Itoa(out.Version)) + jsonOK(w, out) +} + +func (h *Handler) deleteConfigRule(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + id := r.PathValue("id") + if err := h.cfg.Delete(confmgr.KindRule, id); err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.rule.delete", id) + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) listConfigRuleVersions(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + versions, err := h.cfg.Versions(confmgr.KindRule, r.PathValue("id")) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, versions) +} + +func (h *Handler) getConfigRuleVersion(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + rule, err := h.cfg.GetRuleVersion(r.PathValue("id"), version) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, rule) +} + +func (h *Handler) promoteConfigRule(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + id := r.PathValue("id") + if err := h.cfg.Promote(confmgr.KindRule, id, version); err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.rule.promote", id+" v"+strconv.Itoa(version)) + rule, err := h.cfg.GetRule(id) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + jsonOK(w, rule) +} + +func (h *Handler) forkConfigRule(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + version, err := pathVersion(r) + if err != nil { + jsonError(w, http.StatusBadRequest, "invalid version") + return + } + id := r.PathValue("id") + newID, err := h.cfg.Fork(confmgr.KindRule, id, version) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + h.recordMutation(r, "config.rule.fork", id+" v"+strconv.Itoa(version)+" -> "+newID) + rule, err := h.cfg.GetRule(newID) + if err != nil { + jsonError(w, configStatus(err), err.Error()) + return + } + w.WriteHeader(http.StatusCreated) + jsonOK(w, rule) +} + +// checkConfigRule compiles a (possibly unsaved) CUE source and, when sample +// values are supplied, evaluates it against them. It powers the editor's live +// validation panel; nothing is persisted. +func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) { + if !h.configEnabled(w) { + return + } + var body struct { + Source string `json:"source"` + Values map[string]any `json:"values"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values)) +} diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 2dfe0b2..f9fef51 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -251,6 +251,31 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V } } +// ReadNow performs a one-shot synchronous read of a signal's current value. +// It starts a dedicated upstream subscription, returns the first value the data +// source delivers, then tears the subscription down. The read is bounded by ctx +// (callers should pass a timeout). This bypasses the shared fan-out cache because +// not every signal of interest (e.g. arbitrary config-set targets) is otherwise +// subscribed. +func (b *Broker) ReadNow(ctx context.Context, ref SignalRef) (datasource.Value, error) { + ds, ok := b.Source(ref.DS) + if !ok { + return datasource.Value{}, fmt.Errorf("unknown data source %q", ref.DS) + } + ch := make(chan datasource.Value, 1) + cancel, err := ds.Subscribe(ctx, ref.Name, ch) + if err != nil { + return datasource.Value{}, fmt.Errorf("read %s/%s: %w", ref.DS, ref.Name, err) + } + defer cancel() + select { + case v := <-ch: + return v, nil + case <-ctx.Done(): + return datasource.Value{}, ctx.Err() + } +} + // ActiveSubscriptions returns the number of currently active upstream signal // subscriptions. Useful for diagnostics and tests. func (b *Broker) ActiveSubscriptions() int { diff --git a/internal/confmgr/cue.go b/internal/confmgr/cue.go new file mode 100644 index 0000000..99e72d5 --- /dev/null +++ b/internal/confmgr/cue.go @@ -0,0 +1,199 @@ +package confmgr + +import ( + "fmt" + "reflect" + "strings" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + cueerrors "cuelang.org/go/cue/errors" +) + +// ConfigRule is a CUE-based validation/transformation rule bound to a config +// set. Its Source is CUE describing constraints and/or derivations over the +// instance's parameter values. Regular CUE fields whose key matches a set +// parameter are unified with the instance value; constraints that fail produce +// violations, and concrete fields that differ from the instance value are +// reported (and persisted) as transformations. Hidden fields (_x) and +// definitions (#X) are available for helpers and are excluded from both +// concreteness checks and transformation output. Rules are versioned git-style, +// exactly like sets and instances. +type ConfigRule struct { + ID string `json:"id"` + Name string `json:"name"` + SetID string `json:"setId"` + Description string `json:"description,omitempty"` + Version int `json:"version"` + Tag string `json:"tag,omitempty"` + Owner string `json:"owner,omitempty"` + Source string `json:"source"` +} + +// RuleViolation is a single constraint failure from evaluating a rule. +type RuleViolation struct { + Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs) + Path string `json:"path,omitempty"` // dotted parameter path, when known + Message string `json:"message"` +} + +// RuleResult is the outcome of evaluating one or more rules against a set of +// instance values. OK is true only when there is no compile error and no +// violation. Transformed holds the parameter values the rule(s) derived or +// overrode (keys are parameter keys; values are the new concrete values). +type RuleResult struct { + OK bool `json:"ok"` + Violations []RuleViolation `json:"violations,omitempty"` + Transformed map[string]any `json:"transformed,omitempty"` + CompileError string `json:"compileError,omitempty"` +} + +// RuleError wraps a failing RuleResult so it can flow through the store's +// error-returning API while preserving the structured violations. +type RuleError struct { + Result RuleResult +} + +func (e *RuleError) Error() string { + if e.Result.CompileError != "" { + return "rule compile error: " + e.Result.CompileError + } + msgs := make([]string, 0, len(e.Result.Violations)) + for _, v := range e.Result.Violations { + if v.Path != "" { + msgs = append(msgs, v.Path+": "+v.Message) + } else { + msgs = append(msgs, v.Message) + } + } + if len(msgs) == 0 { + return "rule validation failed" + } + return "rule validation failed: " + strings.Join(msgs, "; ") +} + +// Validate compiles the rule's CUE source and checks basic metadata. It does +// not require concrete values — only that the source is syntactically and +// structurally well-formed CUE. +func (r ConfigRule) Validate() error { + if strings.TrimSpace(r.Name) == "" { + return fmt.Errorf("rule name must not be empty") + } + if strings.TrimSpace(r.SetID) == "" { + return fmt.Errorf("rule must reference a config set") + } + ctx := cuecontext.New() + v := ctx.CompileString(r.Source) + if err := v.Err(); err != nil { + return fmt.Errorf("invalid CUE: %s", firstError(err)) + } + return nil +} + +// EvaluateRule unifies a single CUE source with the given instance values and +// reports violations plus any transformed values. A compile error yields a +// non-OK result with CompileError populated (and no violations), so callers can +// distinguish "the rule is broken" from "the values are invalid". +func EvaluateRule(source string, values map[string]any) RuleResult { + ctx := cuecontext.New() + schema := ctx.CompileString(source) + if err := schema.Err(); err != nil { + return RuleResult{OK: false, CompileError: firstError(err)} + } + data := ctx.Encode(values) + if err := data.Err(); err != nil { + return RuleResult{OK: false, CompileError: "cannot encode values: " + firstError(err)} + } + unified := schema.Unify(data) + + res := RuleResult{OK: true} + if err := unified.Validate(cue.Concrete(true), cue.All()); err != nil { + res.OK = false + for _, e := range cueerrors.Errors(err) { + res.Violations = append(res.Violations, RuleViolation{ + Path: strings.Join(e.Path(), "."), + Message: cleanMessage(e), + }) + } + if len(res.Violations) == 0 { + res.Violations = append(res.Violations, RuleViolation{Message: firstError(err)}) + } + return res + } + + // Extract transformations: regular concrete fields whose value differs from + // the supplied input. Definitions and hidden fields are skipped by Fields(). + iter, err := unified.Fields() + if err != nil { + return res + } + for iter.Next() { + key := iter.Selector().Unquoted() + if key == "" { + key = strings.Trim(iter.Selector().String(), `"`) + } + var v any + if err := iter.Value().Decode(&v); err != nil { + continue + } + if !reflect.DeepEqual(v, values[key]) { + if res.Transformed == nil { + res.Transformed = map[string]any{} + } + res.Transformed[key] = v + } + } + return res +} + +// evaluateRules runs several rules against the same values and aggregates the +// outcome. Violations are tagged with the rule ID. Transformations are applied +// cumulatively in order; a later rule sees earlier rules' outputs. +func evaluateRules(rules []ConfigRule, values map[string]any) RuleResult { + agg := RuleResult{OK: true} + cur := make(map[string]any, len(values)) + for k, v := range values { + cur[k] = v + } + for _, r := range rules { + one := EvaluateRule(r.Source, cur) + if one.CompileError != "" { + agg.OK = false + agg.Violations = append(agg.Violations, RuleViolation{Rule: r.ID, Message: "compile error: " + one.CompileError}) + continue + } + if !one.OK { + agg.OK = false + for _, v := range one.Violations { + v.Rule = r.ID + agg.Violations = append(agg.Violations, v) + } + continue + } + for k, v := range one.Transformed { + if agg.Transformed == nil { + agg.Transformed = map[string]any{} + } + agg.Transformed[k] = v + cur[k] = v + } + } + return agg +} + +// firstError renders the first CUE error as a single line. +func firstError(err error) string { + errs := cueerrors.Errors(err) + if len(errs) == 0 { + return err.Error() + } + return cleanMessage(errs[0]) +} + +// cleanMessage formats a CUE error to a compact single-line string without the +// noisy file:line position prefix that cuecontext synthesises for anonymous +// sources. +func cleanMessage(e cueerrors.Error) string { + format, args := e.Msg() + return fmt.Sprintf(format, args...) +} diff --git a/internal/confmgr/cue_test.go b/internal/confmgr/cue_test.go new file mode 100644 index 0000000..fac38d1 --- /dev/null +++ b/internal/confmgr/cue_test.go @@ -0,0 +1,80 @@ +package confmgr + +import "testing" + +func TestEvaluateRule_Valid(t *testing.T) { + src := ` +current_limit: >=0 & <=max +max: 100 +` + res := EvaluateRule(src, map[string]any{"current_limit": 50.0}) + if !res.OK { + t.Fatalf("expected OK, got violations: %+v compile=%q", res.Violations, res.CompileError) + } + if res.CompileError != "" { + t.Fatalf("unexpected compile error: %s", res.CompileError) + } +} + +func TestEvaluateRule_Violation(t *testing.T) { + src := `current_limit: >=0 & <=100` + res := EvaluateRule(src, map[string]any{"current_limit": 150.0}) + if res.OK { + t.Fatalf("expected violation, got OK") + } + if len(res.Violations) == 0 { + t.Fatalf("expected at least one violation") + } +} + +func TestEvaluateRule_Transform(t *testing.T) { + // power is derived from the supplied current & voltage. + src := ` +current: number +voltage: number +power: current * voltage +` + res := EvaluateRule(src, map[string]any{"current": 2.0, "voltage": 3.0}) + if !res.OK { + t.Fatalf("expected OK, got %+v", res.Violations) + } + got, ok := res.Transformed["power"] + if !ok { + t.Fatalf("expected transformed power, got %+v", res.Transformed) + } + if f, _ := toFloat(got); f != 6 { + t.Fatalf("expected power=6, got %v", got) + } +} + +func TestEvaluateRule_DefaultFillsMissing(t *testing.T) { + src := `mode: *"auto" | "manual"` + res := EvaluateRule(src, map[string]any{}) + if !res.OK { + t.Fatalf("expected OK, got %+v", res.Violations) + } + if res.Transformed["mode"] != "auto" { + t.Fatalf("expected mode default auto, got %v", res.Transformed["mode"]) + } +} + +func TestEvaluateRule_CompileError(t *testing.T) { + res := EvaluateRule(`this is : : not cue`, map[string]any{}) + if res.OK || res.CompileError == "" { + t.Fatalf("expected compile error, got %+v", res) + } +} + +func TestConfigRule_Validate(t *testing.T) { + r := ConfigRule{Name: "r", SetID: "s", Source: `x: >=0`} + if err := r.Validate(); err != nil { + t.Fatalf("expected valid rule, got %v", err) + } + bad := ConfigRule{Name: "r", SetID: "s", Source: `x: : :`} + if err := bad.Validate(); err == nil { + t.Fatalf("expected compile error for bad source") + } + if err := (ConfigRule{Source: `x: 1`}).Validate(); err == nil { + t.Fatalf("expected error for missing name/setID") + } +} diff --git a/internal/confmgr/snapshot.go b/internal/confmgr/snapshot.go new file mode 100644 index 0000000..8081414 --- /dev/null +++ b/internal/confmgr/snapshot.go @@ -0,0 +1,129 @@ +package confmgr + +import ( + "fmt" + "math" + "slices" + "strconv" +) + +// ReadFunc reads the current raw value of a target signal. The API/engine layer +// supplies a closure backed by the broker (ReadNow) so confmgr stays decoupled +// from the transport. The returned value mirrors datasource.Value.Data +// (float64 | []float64 | string | int64 | bool; enums arrive as an int64 index). +type ReadFunc func(ds, signal string) (any, error) + +// SnapshotEntry records the outcome of reading one parameter's target signal. +type SnapshotEntry struct { + Key string `json:"key"` + DS string `json:"ds"` + Signal string `json:"signal"` + Value any `json:"value,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +// SnapshotResult summarises a snapshot run. Values holds the captured, +// type-coerced values ready to populate a new ConfigInstance. +type SnapshotResult struct { + SetID string `json:"setId"` + Entries []SnapshotEntry `json:"entries"` + Captured int `json:"captured"` + Failed int `json:"failed"` + Values map[string]any `json:"-"` +} + +// Snapshot reads the current value of every parameter's target signal via read +// and builds a value map for a new instance. Read failures and values that +// cannot be coerced to the parameter's type are recorded per-entry rather than +// aborting the whole snapshot, so a partial capture is reported faithfully. +func Snapshot(set ConfigSet, read ReadFunc) SnapshotResult { + res := SnapshotResult{ + SetID: set.ID, + Entries: make([]SnapshotEntry, 0, len(set.Parameters)), + Values: make(map[string]any, len(set.Parameters)), + } + for _, p := range set.Parameters { + e := SnapshotEntry{Key: p.Key, DS: p.DS, Signal: p.Signal} + raw, err := read(p.DS, p.Signal) + if err != nil { + e.Error = err.Error() + res.Failed++ + res.Entries = append(res.Entries, e) + continue + } + v, err := p.coerceSnapshot(raw) + if err != nil { + e.Error = err.Error() + res.Failed++ + res.Entries = append(res.Entries, e) + continue + } + e.Value = v + e.OK = true + res.Values[p.Key] = v + res.Captured++ + res.Entries = append(res.Entries, e) + } + return res +} + +// coerceSnapshot converts a raw datasource value into the canonical Go value the +// parameter's type expects, so the result passes checkValue and serialises +// cleanly. Enum signals arrive as an int64 index and are mapped to their string. +func (p Parameter) coerceSnapshot(raw any) (any, error) { + switch p.Type { + case TypeFloat: + return toFloat(raw) + case TypeInt: + f, err := toFloat(raw) + if err != nil { + return nil, err + } + return int64(math.Round(f)), nil + case TypeBool: + switch b := raw.(type) { + case bool: + return b, nil + case string: + pb, err := strconv.ParseBool(b) + if err != nil { + return nil, fmt.Errorf("value %q is not a bool", b) + } + return pb, nil + default: + f, err := toFloat(raw) + if err != nil { + return nil, fmt.Errorf("value %v is not a bool", raw) + } + return f != 0, nil + } + case TypeString: + if s, ok := raw.(string); ok { + return s, nil + } + return fmt.Sprintf("%v", raw), nil + case TypeEnum: + // Enum signals deliver an int64 index into the metadata strings; map it + // to this parameter's enum value. A string already in range is kept. + if s, ok := raw.(string); ok { + if slices.Contains(p.EnumValues, s) { + return s, nil + } + return nil, fmt.Errorf("value %q is not an allowed enum value", s) + } + f, err := toFloat(raw) + if err != nil { + return nil, fmt.Errorf("enum value %v is not an index", raw) + } + idx := int(math.Round(f)) + if idx < 0 || idx >= len(p.EnumValues) { + return nil, fmt.Errorf("enum index %d out of range", idx) + } + return p.EnumValues[idx], nil + case TypeFloatArray: + return toFloatArray(raw) + default: + return raw, nil + } +} diff --git a/internal/confmgr/snapshot_test.go b/internal/confmgr/snapshot_test.go new file mode 100644 index 0000000..e970089 --- /dev/null +++ b/internal/confmgr/snapshot_test.go @@ -0,0 +1,83 @@ +package confmgr + +import ( + "errors" + "testing" +) + +func TestSnapshotCapturesAndCoerces(t *testing.T) { + set := ConfigSet{ + ID: "set1", + Name: "s", + Parameters: []Parameter{ + {Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat}, + {Key: "n", DS: "epics", Signal: "PSU:N", Type: TypeInt}, + {Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool}, + {Key: "mode", DS: "epics", Signal: "PSU:MODE", Type: TypeEnum, EnumValues: []string{"off", "low", "high"}}, + {Key: "wf", DS: "epics", Signal: "PSU:WF", Type: TypeFloatArray}, + {Key: "lbl", DS: "epics", Signal: "PSU:LBL", Type: TypeString}, + }, + } + live := map[string]any{ + "PSU:V": 24.5, + "PSU:N": 3.0, // float reading → coerced to int64 + "PSU:EN": int64(1), // numeric → bool true + "PSU:MODE": int64(2), // enum index → "high" + "PSU:WF": []float64{1, 2, 3}, + "PSU:LBL": "ready", + } + res := Snapshot(set, func(_, signal string) (any, error) { + v, ok := live[signal] + if !ok { + return nil, errors.New("not read") + } + return v, nil + }) + + if res.Captured != 6 || res.Failed != 0 { + t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed) + } + if res.Values["v"] != 24.5 { + t.Errorf("v = %v, want 24.5", res.Values["v"]) + } + if res.Values["n"] != int64(3) { + t.Errorf("n = %v (%T), want int64(3)", res.Values["n"], res.Values["n"]) + } + if res.Values["en"] != true { + t.Errorf("en = %v, want true", res.Values["en"]) + } + if res.Values["mode"] != "high" { + t.Errorf("mode = %v, want high", res.Values["mode"]) + } + if res.Values["lbl"] != "ready" { + t.Errorf("lbl = %v, want ready", res.Values["lbl"]) + } + // The captured values must validate against the set. + inst := ConfigInstance{SetID: set.ID, Values: res.Values} + if err := inst.ValidateAgainst(set); err != nil { + t.Errorf("snapshot values fail validation: %v", err) + } +} + +func TestSnapshotReadFailureRecorded(t *testing.T) { + set := ConfigSet{ + ID: "set1", + Name: "s", + Parameters: []Parameter{ + {Key: "a", DS: "epics", Signal: "A", Type: TypeFloat}, + {Key: "b", DS: "epics", Signal: "B", Type: TypeFloat}, + }, + } + res := Snapshot(set, func(_, signal string) (any, error) { + if signal == "B" { + return nil, errors.New("offline") + } + return 1.0, nil + }) + if res.Captured != 1 || res.Failed != 1 { + t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed) + } + if _, ok := res.Values["b"]; ok { + t.Errorf("failed parameter must not appear in values") + } +} diff --git a/internal/confmgr/store.go b/internal/confmgr/store.go index 5fa90c6..0956b5e 100644 --- a/internal/confmgr/store.go +++ b/internal/confmgr/store.go @@ -22,13 +22,18 @@ type Kind int const ( KindSet Kind = iota KindInstance + KindRule ) func (k Kind) sub() string { - if k == KindInstance { + switch k { + case KindInstance: return "instances" + case KindRule: + return "rules" + default: + return "sets" } - return "sets" } // Meta is the lightweight listing representation. @@ -36,6 +41,10 @@ type Meta struct { ID string `json:"id"` Name string `json:"name"` Version int `json:"version"` + // SetID is only populated for instances (the schema they belong to); it is + // empty for sets. Lets clients group/filter instances by set without a GET + // per instance. + SetID string `json:"setId,omitempty"` } // VersionMeta describes a single persisted revision. @@ -59,7 +68,7 @@ type Store struct { // New opens (and creates, if needed) the configs storage directories. func New(storageDir string) (*Store, error) { s := &Store{rootDir: storageDir, dirs: map[Kind]string{}} - for _, k := range []Kind{KindSet, KindInstance} { + for _, k := range []Kind{KindSet, KindInstance, KindRule} { dir := filepath.Join(storageDir, "configs", k.sub()) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create %s dir: %w", k.sub(), err) @@ -75,6 +84,7 @@ type header struct { Name string `json:"name"` Version int `json:"version"` Tag string `json:"tag"` + SetID string `json:"setId"` } func validateID(id string) error { @@ -139,7 +149,7 @@ func (s *Store) List(k Kind) ([]Meta, error) { if err != nil { continue } - out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version}) + out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID}) } return out, nil } @@ -462,6 +472,9 @@ func (s *Store) CreateInstance(inst ConfigInstance, tag string) (ConfigInstance, if err := inst.ValidateAgainst(set); err != nil { return ConfigInstance{}, err } + if err := s.applyRules(&inst, set); err != nil { + return ConfigInstance{}, err + } obj, err := toMap(inst) if err != nil { return ConfigInstance{}, err @@ -483,6 +496,9 @@ func (s *Store) UpdateInstance(id string, inst ConfigInstance, tag string) (Conf if err := inst.ValidateAgainst(set); err != nil { return ConfigInstance{}, err } + if err := s.applyRules(&inst, set); err != nil { + return ConfigInstance{}, err + } obj, err := toMap(inst) if err != nil { return ConfigInstance{}, err @@ -500,6 +516,124 @@ func (s *Store) SetForInstance(inst ConfigInstance) (ConfigSet, error) { return s.setForInstance(inst) } +// ── rules ──────────────────────────────────────────────────────────────────── + +// GetRule returns the current revision of a CUE validation/transformation rule. +func (s *Store) GetRule(id string) (ConfigRule, error) { + data, err := s.get(KindRule, id) + if err != nil { + return ConfigRule{}, err + } + var rule ConfigRule + return rule, json.Unmarshal(data, &rule) +} + +// GetRuleVersion returns a specific revision of a rule. +func (s *Store) GetRuleVersion(id string, version int) (ConfigRule, error) { + data, err := s.getVersion(KindRule, id, version) + if err != nil { + return ConfigRule{}, err + } + var rule ConfigRule + return rule, json.Unmarshal(data, &rule) +} + +// CreateRule validates the rule's CUE source and stores it. +func (s *Store) CreateRule(rule ConfigRule, tag string) (ConfigRule, error) { + if err := rule.Validate(); err != nil { + return ConfigRule{}, err + } + obj, err := toMap(rule) + if err != nil { + return ConfigRule{}, err + } + _, data, err := s.create(KindRule, obj, tag) + if err != nil { + return ConfigRule{}, err + } + var out ConfigRule + return out, json.Unmarshal(data, &out) +} + +// UpdateRule validates and stores a new revision of an existing rule. +func (s *Store) UpdateRule(id string, rule ConfigRule, tag string) (ConfigRule, error) { + if err := rule.Validate(); err != nil { + return ConfigRule{}, err + } + obj, err := toMap(rule) + if err != nil { + return ConfigRule{}, err + } + data, err := s.update(KindRule, id, obj, tag) + if err != nil { + return ConfigRule{}, err + } + var out ConfigRule + return out, json.Unmarshal(data, &out) +} + +// rulesForSet loads every current-revision rule bound to the given set. +func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) { + metas, err := s.List(KindRule) + if err != nil { + return nil, err + } + var out []ConfigRule + for _, m := range metas { + if m.SetID != setID { + continue + } + rule, err := s.GetRule(m.ID) + if err != nil { + continue + } + out = append(out, rule) + } + return out, nil +} + +// applyRules runs every rule bound to inst's set against its values. On +// success it merges rule-derived values back into inst (parameter keys only) so +// the stored instance reflects the canonical, transformed configuration. A +// violation is returned as *RuleError so the caller can surface the structured +// details. +func (s *Store) applyRules(inst *ConfigInstance, set ConfigSet) error { + rules, err := s.rulesForSet(inst.SetID) + if err != nil { + return err + } + if len(rules) == 0 { + return nil + } + res := evaluateRules(rules, inst.Values) + if !res.OK { + return &RuleError{Result: res} + } + for k, v := range res.Transformed { + if _, ok := set.param(k); ok { + if inst.Values == nil { + inst.Values = map[string]any{} + } + inst.Values[k] = v + } + } + return nil +} + +// ValidateInstanceRules evaluates the stored rules for an instance without +// persisting anything. It is the read-only counterpart used by the validate +// endpoint. +func (s *Store) ValidateInstanceRules(inst ConfigInstance) (RuleResult, error) { + rules, err := s.rulesForSet(inst.SetID) + if err != nil { + return RuleResult{}, err + } + if len(rules) == 0 { + return RuleResult{OK: true}, nil + } + return evaluateRules(rules, inst.Values), nil +} + // ── JSON helpers ──────────────────────────────────────────────────────────── func name(obj map[string]any) string { diff --git a/internal/confmgr/store_test.go b/internal/confmgr/store_test.go index 9e7d5af..b7480b7 100644 --- a/internal/confmgr/store_test.go +++ b/internal/confmgr/store_test.go @@ -41,6 +41,76 @@ func TestCreateAndGetSet(t *testing.T) { } } +func TestRuleBlocksInvalidInstance(t *testing.T) { + s, _ := New(t.TempDir()) + set, _ := s.CreateSet(sampleSet(), "") + if _, err := s.CreateRule(ConfigRule{ + Name: "voltage cap", + SetID: set.ID, + Source: "voltage: <=24", + }, ""); err != nil { + t.Fatal(err) + } + + // In-range value passes. + if _, err := s.CreateInstance(ConfigInstance{ + Name: "ok", SetID: set.ID, Values: map[string]any{"voltage": 20.0}, + }, ""); err != nil { + t.Fatalf("expected in-range instance to save, got %v", err) + } + + // Out-of-range value is rejected by the rule. + _, err := s.CreateInstance(ConfigInstance{ + Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0}, + }, "") + if err == nil { + t.Fatalf("expected rule violation to block save") + } + var re *RuleError + if !asRuleError(err, &re) { + t.Fatalf("expected *RuleError, got %T: %v", err, err) + } +} + +func TestRuleTransformPersists(t *testing.T) { + s, _ := New(t.TempDir()) + set := sampleSet() + max := 48.0 + set.Parameters = append(set.Parameters, Parameter{Key: "vmax", DS: "epics", Signal: "PSU:VMAX", Type: TypeFloat, Min: &max}) + created, _ := s.CreateSet(set, "") + // Rule derives vmax = voltage * 2 (within range for voltage=20 -> 40). + if _, err := s.CreateRule(ConfigRule{ + Name: "vmax derive", SetID: created.ID, Source: "voltage: number\nvmax: voltage * 2", + }, ""); err != nil { + t.Fatal(err) + } + inst, err := s.CreateInstance(ConfigInstance{ + Name: "x", SetID: created.ID, Values: map[string]any{"voltage": 20.0}, + }, "") + if err != nil { + t.Fatal(err) + } + if f, _ := toFloat(inst.Values["vmax"]); f != 40 { + t.Fatalf("expected derived vmax=40 to persist, got %v", inst.Values["vmax"]) + } +} + +func asRuleError(err error, target **RuleError) bool { + for err != nil { + if re, ok := err.(*RuleError); ok { + *target = re + return true + } + type unwrapper interface{ Unwrap() error } + u, ok := err.(unwrapper) + if !ok { + return false + } + err = u.Unwrap() + } + return false +} + func TestSetVersioning(t *testing.T) { s, _ := New(t.TempDir()) created, _ := s.CreateSet(sampleSet(), "") diff --git a/internal/controllogic/config_node_test.go b/internal/controllogic/config_node_test.go new file mode 100644 index 0000000..88f45d0 --- /dev/null +++ b/internal/controllogic/config_node_test.go @@ -0,0 +1,234 @@ +package controllogic + +import ( + "context" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/uopi/uopi/internal/audit" + "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/confmgr" + "github.com/uopi/uopi/internal/datasource" +) + +// writableSource is a minimal in-memory DataSource that records the last value +// written to each signal, so config-apply/read nodes can be tested end-to-end. +type writableSource struct { + mu sync.Mutex + written map[string]any +} + +func newWritableSource() *writableSource { return &writableSource{written: map[string]any{}} } + +func (s *writableSource) Name() string { return "tgt" } +func (s *writableSource) Connect(context.Context) error { return nil } +func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, error) { + return nil, nil +} +func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) { + return datasource.Metadata{Name: sig, Writable: true}, nil +} +// Subscribe delivers the signal's last-written value once (if any), so a +// one-shot ReadNow (used by config snapshot) resolves immediately. +func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) { + s.mu.Lock() + v, ok := s.written[sig] + s.mu.Unlock() + if ok { + select { + case ch <- datasource.Value{Data: v, Timestamp: time.Now()}: + default: + } + } + return func() {}, nil +} +func (s *writableSource) Write(_ context.Context, signal string, value any) error { + s.mu.Lock() + defer s.mu.Unlock() + s.written[signal] = value + return nil +} +func (s *writableSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) { + return nil, datasource.ErrHistoryUnavailable +} + +func (s *writableSource) get(signal string) (any, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.written[signal] + return v, ok +} + +// setupConfigEngine wires a broker (with a writable "tgt" source), a confmgr +// store seeded with a set + instance, and an Engine bound to both. +func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) { + t.Helper() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + ctx := context.Background() + + src := newWritableSource() + brk := broker.New(ctx, log) + brk.Register(src) + + cfg, err := confmgr.New(t.TempDir()) + if err != nil { + t.Fatal("confmgr.New:", err) + } + set, err := cfg.CreateSet(confmgr.ConfigSet{ + Name: "tuning", + Parameters: []confmgr.Parameter{ + {Key: "gain", DS: "tgt", Signal: "GAIN", Type: confmgr.TypeFloat, Default: 1.0}, + {Key: "offset", DS: "tgt", Signal: "OFFSET", Type: confmgr.TypeFloat, Default: 0.0}, + }, + }, "") + if err != nil { + t.Fatal("CreateSet:", err) + } + inst, err := cfg.CreateInstance(confmgr.ConfigInstance{ + Name: "warm", + SetID: set.ID, + Values: map[string]any{"gain": 2.5, "offset": 10.0}, + }, "") + if err != nil { + t.Fatal("CreateInstance:", err) + } + + e := NewEngine(ctx, brk, nil, cfg, audit.Nop(), log) + return e, src, inst.ID +} + +func TestApplyConfigWritesEveryParam(t *testing.T) { + e, src, instID := setupConfigEngine(t) + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + + e.applyConfig(cg, instID) + + if got, ok := src.get("GAIN"); !ok || toNum(got) != 2.5 { + t.Errorf("GAIN = %v (ok=%v), want 2.5", got, ok) + } + if got, ok := src.get("OFFSET"); !ok || toNum(got) != 10.0 { + t.Errorf("OFFSET = %v (ok=%v), want 10.0", got, ok) + } +} + +func TestApplyConfigUnknownInstanceNoop(t *testing.T) { + e, src, _ := setupConfigEngine(t) + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + + e.applyConfig(cg, "does-not-exist") + + if len(src.written) != 0 { + t.Errorf("expected no writes, got %v", src.written) + } +} + +func TestReadConfigParam(t *testing.T) { + e, _, instID := setupConfigEngine(t) + + if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 2.5 { + t.Errorf("readConfigParam(gain) = %v (ok=%v), want 2.5", v, ok) + } + // Missing param key. + if _, ok := e.readConfigParam(instID, "nope"); ok { + t.Errorf("readConfigParam(nope) returned ok=true, want false") + } + // Missing instance. + if _, ok := e.readConfigParam("does-not-exist", "gain"); ok { + t.Errorf("readConfigParam(missing instance) returned ok=true, want false") + } +} + +func TestWriteConfigParam(t *testing.T) { + e, _, instID := setupConfigEngine(t) + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + + e.writeConfigParam(cg, instID, "gain", 7.5) + + // A new revision should now resolve to the written value. + if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 7.5 { + t.Errorf("after write, gain = %v (ok=%v), want 7.5", v, ok) + } + inst, err := e.cfg.GetInstance(instID) + if err != nil { + t.Fatal("GetInstance:", err) + } + if inst.Version < 2 { + t.Errorf("instance version = %d, want >= 2 (a new revision)", inst.Version) + } +} + +func TestCreateConfigInstance(t *testing.T) { + e, _, srcID := setupConfigEngine(t) + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + + src, err := e.cfg.GetInstance(srcID) + if err != nil { + t.Fatal("GetInstance:", err) + } + + e.createConfigInstance(cg, src.SetID, "cold", srcID) + + insts, err := e.cfg.List(confmgr.KindInstance) + if err != nil { + t.Fatal("List:", err) + } + var found *confmgr.ConfigInstance + for i := range insts { + if insts[i].Name == "cold" { + full, err := e.cfg.GetInstance(insts[i].ID) + if err != nil { + t.Fatal("GetInstance:", err) + } + found = &full + } + } + if found == nil { + t.Fatal("created instance 'cold' not found") + } + // Values copied from the source instance. + if got := toNum(found.Values["gain"]); got != 2.5 { + t.Errorf("copied gain = %v, want 2.5", got) + } +} + +func TestSnapshotConfig(t *testing.T) { + e, src, instID := setupConfigEngine(t) + cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}} + + // Seed current live values for the set's target signals. + _ = src.Write(context.Background(), "GAIN", 3.5) + _ = src.Write(context.Background(), "OFFSET", -2.0) + + seed, err := e.cfg.GetInstance(instID) + if err != nil { + t.Fatal("GetInstance:", err) + } + e.snapshotConfig(cg, seed.SetID, "snap1") + + insts, err := e.cfg.List(confmgr.KindInstance) + if err != nil { + t.Fatal("List:", err) + } + var found *confmgr.ConfigInstance + for i := range insts { + if insts[i].Name == "snap1" { + full, err := e.cfg.GetInstance(insts[i].ID) + if err != nil { + t.Fatal("GetInstance:", err) + } + found = &full + } + } + if found == nil { + t.Fatal("snapshot instance 'snap1' not found") + } + if got := toNum(found.Values["gain"]); got != 3.5 { + t.Errorf("snapshot gain = %v, want 3.5 (current live value)", got) + } + if got := toNum(found.Values["offset"]); got != -2.0 { + t.Errorf("snapshot offset = %v, want -2.0 (current live value)", got) + } +} diff --git a/internal/controllogic/engine.go b/internal/controllogic/engine.go index a863f28..bf719ef 100644 --- a/internal/controllogic/engine.go +++ b/internal/controllogic/engine.go @@ -2,6 +2,8 @@ package controllogic import ( "context" + "errors" + "fmt" "log/slog" "math" "regexp" @@ -13,8 +15,25 @@ import ( "github.com/uopi/uopi/internal/audit" "github.com/uopi/uopi/internal/broker" + "github.com/uopi/uopi/internal/confmgr" ) +// errUnknownSource is returned by config-apply writes targeting a data source +// that isn't registered with the broker. +var errUnknownSource = errors.New("unknown data source") + +// formatAny renders a config value for the audit log. +func formatAny(v any) string { + switch x := v.(type) { + case float64: + return strconv.FormatFloat(x, 'g', -1, 64) + case string: + return x + default: + return fmt.Sprintf("%v", v) + } +} + // Guards against runaway flows (cycles / pathological loops). const ( maxSteps = 100000 @@ -27,6 +46,7 @@ const ( type Engine struct { broker *broker.Broker store *Store + cfg *confmgr.Store audit audit.Recorder log *slog.Logger root context.Context @@ -59,13 +79,14 @@ func (e *Engine) SetNotifier(n Notifier) { // NewEngine creates an engine bound to root. Call Reload to start it. rec records // the writes performed by flows; pass audit.Nop() to disable auditing. -func NewEngine(root context.Context, brk *broker.Broker, store *Store, rec audit.Recorder, log *slog.Logger) *Engine { +func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine { if rec == nil { rec = audit.Nop() } return &Engine{ broker: brk, store: store, + cfg: cfg, audit: rec, log: log, root: root, @@ -270,6 +291,237 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) { e.audit.Record(ev) } +// applyConfig resolves a config instance + its set and writes every parameter +// to its target signal via the owning data source (confmgr.Apply). Each write is +// audited. A bare instance id or a missing config store is a no-op (logged). +func (e *Engine) applyConfig(cg *compiledGraph, instanceID string) { + if e.cfg == nil || instanceID == "" { + return + } + inst, err := e.cfg.GetInstance(instanceID) + if err != nil { + e.log.Warn("control logic: config apply: unknown instance", "instance", instanceID, "err", err) + return + } + set, err := e.cfg.SetForInstance(inst) + if err != nil { + e.log.Warn("control logic: config apply: set lookup failed", "instance", instanceID, "err", err) + return + } + write := func(ds, signal string, value any) error { + ev := audit.Event{ + Actor: cg.name, + ActorType: audit.ActorSystem, + Action: "signal.write", + DS: ds, + Signal: signal, + Value: formatAny(value), + Detail: "control logic config apply: " + inst.Name, + Outcome: audit.OutcomeOK, + } + var werr error + if ds == "local" { + cg.setLocal(signal, toNum(value)) + } else if src, ok := e.broker.Source(ds); ok { + werr = src.Write(e.root, signal, value) + } else { + werr = errUnknownSource + } + if werr != nil { + ev.Outcome = audit.OutcomeError + ev.Error = werr.Error() + } + e.audit.Record(ev) + return werr + } + confmgr.Apply(set, inst, write) +} + +// readConfigParam resolves a single parameter's value from a config instance, +// coercing it to float64 for use as a control-logic value. Returns ok=false if +// the store/instance/param is missing or the value isn't numeric. +func (e *Engine) readConfigParam(instanceID, key string) (float64, bool) { + if e.cfg == nil || instanceID == "" || key == "" { + return 0, false + } + inst, err := e.cfg.GetInstance(instanceID) + if err != nil { + e.log.Warn("control logic: config read: unknown instance", "instance", instanceID, "err", err) + return 0, false + } + set, err := e.cfg.SetForInstance(inst) + if err != nil { + e.log.Warn("control logic: config read: set lookup failed", "instance", instanceID, "err", err) + return 0, false + } + for _, p := range set.Parameters { + if p.Key != key { + continue + } + v, ok := inst.Resolve(p) + if !ok { + return 0, false + } + f := toNum(v) + if math.IsNaN(f) { + return 0, false + } + return f, true + } + return 0, false +} + +// writeConfigParam sets a single parameter's value on a config instance and +// saves a new revision (git-style). The value is coerced to the parameter's +// declared type (numeric/bool/string) so it validates against the set. A +// missing store/instance/param is a no-op (logged). Audited. +func (e *Engine) writeConfigParam(cg *compiledGraph, instanceID, key string, val float64) { + if e.cfg == nil || instanceID == "" || key == "" { + return + } + inst, err := e.cfg.GetInstance(instanceID) + if err != nil { + e.log.Warn("control logic: config write: unknown instance", "instance", instanceID, "err", err) + return + } + set, err := e.cfg.SetForInstance(inst) + if err != nil { + e.log.Warn("control logic: config write: set lookup failed", "instance", instanceID, "err", err) + return + } + var param *confmgr.Parameter + for i := range set.Parameters { + if set.Parameters[i].Key == key { + param = &set.Parameters[i] + break + } + } + if param == nil { + e.log.Warn("control logic: config write: unknown parameter", "instance", instanceID, "key", key) + return + } + if inst.Values == nil { + inst.Values = map[string]any{} + } + inst.Values[key] = coerceParamValue(*param, val) + ev := audit.Event{ + Actor: cg.name, + ActorType: audit.ActorSystem, + Action: "config.instance.update", + Detail: "control logic config write: " + inst.Name + " " + key + "=" + formatAny(inst.Values[key]), + Outcome: audit.OutcomeOK, + } + if _, err := e.cfg.UpdateInstance(instanceID, inst, ""); err != nil { + e.log.Warn("control logic: config write failed", "instance", instanceID, "key", key, "err", err) + ev.Outcome = audit.OutcomeError + ev.Error = err.Error() + } + e.audit.Record(ev) +} + +// createConfigInstance creates a new config instance for setID, optionally +// copying values from an existing instance (fromID). A missing store/set is a +// no-op (logged). Audited. The new instance's id is logged but not written back +// to a flow variable (control-logic variables are numeric, ids are strings). +func (e *Engine) createConfigInstance(cg *compiledGraph, setID, name, fromID string) { + if e.cfg == nil || setID == "" { + return + } + if name == "" { + name = "auto" + } + values := map[string]any{} + if fromID != "" { + if src, err := e.cfg.GetInstance(fromID); err == nil { + for k, v := range src.Values { + values[k] = v + } + } else { + e.log.Warn("control logic: config create: copy source missing", "from", fromID, "err", err) + } + } + inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: values} + ev := audit.Event{ + Actor: cg.name, + ActorType: audit.ActorSystem, + Action: "config.instance.create", + Detail: "control logic config create: set=" + setID + " name=" + name, + Outcome: audit.OutcomeOK, + } + out, err := e.cfg.CreateInstance(inst, "") + if err != nil { + e.log.Warn("control logic: config create failed", "set", setID, "err", err) + ev.Outcome = audit.OutcomeError + ev.Error = err.Error() + } else { + ev.Detail += " -> " + out.ID + } + e.audit.Record(ev) +} + +// snapshotConfig captures the current value of every target signal of a set and +// stores them as a new config instance. A missing store/set is a no-op (logged). +// Audited. The new instance's id is logged but not written back to a flow +// variable (control-logic variables are numeric, ids are strings). +func (e *Engine) snapshotConfig(cg *compiledGraph, setID, name string) { + if e.cfg == nil || setID == "" { + return + } + set, err := e.cfg.GetSet(setID) + if err != nil { + e.log.Warn("control logic: config snapshot: unknown set", "set", setID, "err", err) + return + } + ctx, cancel := context.WithTimeout(e.root, 5*time.Second) + defer cancel() + read := func(ds, signal string) (any, error) { + if ds == "local" { + return cg.getLocal(signal), nil + } + v, err := e.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal}) + if err != nil { + return nil, err + } + return v.Data, nil + } + snap := confmgr.Snapshot(set, read) + if name == "" { + name = set.Name + " snapshot" + } + inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: snap.Values} + ev := audit.Event{ + Actor: cg.name, + ActorType: audit.ActorSystem, + Action: "config.instance.snapshot", + Detail: "control logic config snapshot: set=" + setID + " name=" + name, + Outcome: audit.OutcomeOK, + } + out, err := e.cfg.CreateInstance(inst, "") + if err != nil { + e.log.Warn("control logic: config snapshot failed", "set", setID, "err", err) + ev.Outcome = audit.OutcomeError + ev.Error = err.Error() + } else { + ev.Detail += " -> " + out.ID + " captured=" + strconv.Itoa(snap.Captured) + " failed=" + strconv.Itoa(snap.Failed) + } + e.audit.Record(ev) +} + +// coerceParamValue converts a numeric flow value to the Go type a config +// parameter expects, so the resulting instance validates against its set. +func coerceParamValue(p confmgr.Parameter, val float64) any { + switch p.Type { + case confmgr.TypeInt: + return int64(val) + case confmgr.TypeBool: + return val != 0 + case confmgr.TypeString, confmgr.TypeEnum: + return strconv.FormatFloat(val, 'g', -1, 64) + default: + return val + } +} + // emitDialog delivers an action.dialog node's request to the installed Notifier // (the WebSocket dialog hub). It is lock-free so it never deadlocks against a // concurrent Reload that is waiting for this flow goroutine to finish. @@ -673,6 +925,30 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) { cg.engine.write(cg, node.param("target"), val) cg.follow(node.ID, "out", ctx) + case "action.config.apply": + cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance"))) + cg.follow(node.ID, "out", ctx) + + case "action.config.read": + v, ok := cg.engine.readConfigParam(strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key"))) + if ok { + cg.engine.write(cg, node.param("target"), v) + } + cg.follow(node.ID, "out", ctx) + + case "action.config.write": + val := EvalExpr(node.param("expr"), ctx.resolve) + cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val) + cg.follow(node.ID, "out", ctx) + + case "action.config.create": + cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from"))) + cg.follow(node.ID, "out", ctx) + + case "action.config.snapshot": + cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name"))) + cg.follow(node.ID, "out", ctx) + case "action.delay": ms := 0 if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 { diff --git a/internal/controllogic/model.go b/internal/controllogic/model.go index 0dc753d..e7565f0 100644 --- a/internal/controllogic/model.go +++ b/internal/controllogic/model.go @@ -55,6 +55,8 @@ type Graph struct { ID string `json:"id"` Name string `json:"name"` Enabled bool `json:"enabled"` + Version int `json:"version,omitempty"` // git-style revision; bumped on each Save + Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3") Nodes []Node `json:"nodes"` Wires []Wire `json:"wires"` } diff --git a/internal/controllogic/store.go b/internal/controllogic/store.go index 4401a6c..016997a 100644 --- a/internal/controllogic/store.go +++ b/internal/controllogic/store.go @@ -17,19 +17,25 @@ var ErrNotFound = errors.New("control logic graph not found") // Store persists control-logic graphs as a single JSON file in the storage dir. // Writes are atomic (tmp file + rename); all access is mutex-guarded. +// +// Git-style versioning: the live graph lives in controllogic.json (the current +// revision), while every superseded revision is preserved as a backup file +// {id}.vN.json under versionsDir. Promote/Fork build on these backups. type Store struct { - mu sync.RWMutex - path string - trashDir string - items map[string]Graph + mu sync.RWMutex + path string + trashDir string + versionsDir string + items map[string]Graph } // NewStore opens (or initialises) the control-logic store under storageDir. func NewStore(storageDir string) (*Store, error) { s := &Store{ - path: filepath.Join(storageDir, definitionsFile), - trashDir: filepath.Join(storageDir, "trash", "controllogic"), - items: map[string]Graph{}, + path: filepath.Join(storageDir, definitionsFile), + trashDir: filepath.Join(storageDir, "trash", "controllogic"), + versionsDir: filepath.Join(storageDir, "controllogic_versions"), + items: map[string]Graph{}, } if err := s.load(); err != nil { return nil, err @@ -94,14 +100,46 @@ func (s *Store) Get(id string) (Graph, error) { return g, nil } -// Save inserts or replaces a graph and persists the store. +// Save inserts or replaces a graph and persists the store. When replacing an +// existing graph the superseded revision is backed up as {id}.vN.json and the +// new graph's Version is bumped; a brand-new graph starts at version 1. func (s *Store) Save(g Graph) error { s.mu.Lock() defer s.mu.Unlock() + if old, ok := s.items[g.ID]; ok { + oldV := old.Version + if oldV < 1 { + oldV = 1 + old.Version = 1 + } + if err := s.backupLocked(old); err != nil { + return fmt.Errorf("back up control logic revision: %w", err) + } + g.Version = oldV + 1 + } else if g.Version < 1 { + g.Version = 1 + } s.items[g.ID] = g return s.saveLocked() } +// backupLocked writes a single graph revision to versionsDir as {id}.vN.json. +// Caller must hold s.mu. +func (s *Store) backupLocked(g Graph) error { + if err := os.MkdirAll(s.versionsDir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(g, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.versionPath(g.ID, g.Version), data, 0o644) +} + +func (s *Store) versionPath(id string, version int) string { + return filepath.Join(s.versionsDir, fmt.Sprintf("%s.v%d.json", id, version)) +} + // Delete removes a graph by id, first writing a copy into the trash folder so it // can be recovered if needed. The trash backup is best-effort: a failure to write // it does not prevent the delete (but is reported). diff --git a/internal/controllogic/versions.go b/internal/controllogic/versions.go new file mode 100644 index 0000000..2e89bab --- /dev/null +++ b/internal/controllogic/versions.go @@ -0,0 +1,142 @@ +package controllogic + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strconv" + "strings" + "time" +) + +// VersionMeta describes a single persisted revision of a control-logic graph, +// mirroring storage.VersionMeta so the frontend can treat all versioned +// document types uniformly. +type VersionMeta struct { + Version int `json:"version"` + Name string `json:"name"` + Tag string `json:"tag,omitempty"` + Current bool `json:"current"` + SavedAt time.Time `json:"savedAt"` +} + +// Versions returns metadata for every persisted revision of the graph, newest +// first. The live revision (held in controllogic.json) is flagged Current. +func (s *Store) Versions(id string) ([]VersionMeta, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + cur, ok := s.items[id] + if !ok { + return nil, ErrNotFound + } + curV := cur.Version + if curV < 1 { + curV = 1 + } + + var savedAt time.Time + if info, err := os.Stat(s.path); err == nil { + savedAt = info.ModTime() + } + out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}} + + entries, err := os.ReadDir(s.versionsDir) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + prefix := id + ".v" + for _, e := range entries { + name := e.Name() + if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".json") { + continue + } + vStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".json") + v, err := strconv.Atoi(vStr) + if err != nil { + continue + } + g, err := s.readVersion(id, v) + if err != nil { + continue + } + info, err := e.Info() + if err != nil { + continue + } + out = append(out, VersionMeta{Version: v, Name: g.Name, Tag: g.Tag, SavedAt: info.ModTime()}) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version }) + return out, nil +} + +// GetVersion returns a specific revision of the graph. The current revision is +// served from the live store; older revisions come from their backup file. +func (s *Store) GetVersion(id string, version int) (Graph, error) { + s.mu.RLock() + defer s.mu.RUnlock() + cur, ok := s.items[id] + if !ok { + return Graph{}, ErrNotFound + } + curV := cur.Version + if curV < 1 { + curV = 1 + } + if version == curV { + return cur, nil + } + return s.readVersion(id, version) +} + +// readVersion loads a backup revision file. Caller must hold s.mu. +func (s *Store) readVersion(id string, version int) (Graph, error) { + data, err := os.ReadFile(s.versionPath(id, version)) + if os.IsNotExist(err) { + return Graph{}, ErrNotFound + } + if err != nil { + return Graph{}, err + } + var g Graph + if err := json.Unmarshal(data, &g); err != nil { + return Graph{}, fmt.Errorf("parse revision: %w", err) + } + return g, nil +} + +// Promote makes a past revision current by re-saving it on top of history. The +// existing current revision is preserved as a backup, so promotion is +// non-destructive. Returns the resulting (new current) graph. +func (s *Store) Promote(id string, version int) (Graph, error) { + g, err := s.GetVersion(id, version) + if err != nil { + return Graph{}, err + } + g.Tag = fmt.Sprintf("restored from v%d", version) + if err := s.Save(g); err != nil { + return Graph{}, err + } + return s.Get(id) +} + +// Fork creates a brand-new graph from a specific revision, assigning a fresh id +// and resetting its version to 1. Returns the new graph. +func (s *Store) Fork(id string, version int) (Graph, error) { + g, err := s.GetVersion(id, version) + if err != nil { + return Graph{}, err + } + g.ID = fmt.Sprintf("%s-fork-%d", id, time.Now().UnixMilli()) + g.Version = 1 + g.Tag = "" + if g.Name != "" { + g.Name = g.Name + " (fork)" + } + if err := s.Save(g); err != nil { + return Graph{}, err + } + return g, nil +} diff --git a/internal/controllogic/versions_test.go b/internal/controllogic/versions_test.go new file mode 100644 index 0000000..f190121 --- /dev/null +++ b/internal/controllogic/versions_test.go @@ -0,0 +1,70 @@ +package controllogic + +import "testing" + +func TestControlLogicVersioning(t *testing.T) { + dir := t.TempDir() + s, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + + g := Graph{ID: "cl-1", Name: "loop", Enabled: true, + Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}} + if err := s.Save(g); err != nil { + t.Fatalf("Save v1: %v", err) + } + if got, _ := s.Get("cl-1"); got.Version != 1 { + t.Fatalf("after create: version=%d", got.Version) + } + + // Two edits → v2, v3. + g.Name = "loop-2" + if err := s.Save(g); err != nil { + t.Fatalf("Save v2: %v", err) + } + g.Name = "loop-3" + if err := s.Save(g); err != nil { + t.Fatalf("Save v3: %v", err) + } + if got, _ := s.Get("cl-1"); got.Version != 3 || got.Name != "loop-3" { + t.Fatalf("current: version=%d name=%q", got.Version, got.Name) + } + + versions, err := s.Versions("cl-1") + if err != nil { + t.Fatalf("Versions: %v", err) + } + if len(versions) != 3 { + t.Fatalf("want 3 versions, got %d", len(versions)) + } + if !versions[0].Current || versions[0].Version != 3 { + t.Errorf("newest should be current v3: %+v", versions[0]) + } + + v1, err := s.GetVersion("cl-1", 1) + if err != nil || v1.Name != "loop" { + t.Fatalf("GetVersion v1: name=%q err=%v", v1.Name, err) + } + + // Promote v1 → v4. + promoted, err := s.Promote("cl-1", 1) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if promoted.Version != 4 || promoted.Name != "loop" { + t.Errorf("promote: version=%d name=%q", promoted.Version, promoted.Name) + } + + // Fork v3 → new id, version 1. + forked, err := s.Fork("cl-1", 3) + if err != nil { + t.Fatalf("Fork: %v", err) + } + if forked.Version != 1 || forked.ID == "cl-1" { + t.Errorf("fork: id=%q version=%d", forked.ID, forked.Version) + } + if got, err := s.Get(forked.ID); err != nil || got.Name != forked.Name { + t.Errorf("forked graph not stored: %v", err) + } +} diff --git a/internal/datasource/synthetic/definition.go b/internal/datasource/synthetic/definition.go index 2e1c807..c6e59de 100644 --- a/internal/datasource/synthetic/definition.go +++ b/internal/datasource/synthetic/definition.go @@ -21,6 +21,12 @@ type SignalDef struct { Visibility string `json:"visibility,omitempty"` Owner string `json:"owner,omitempty"` // creator identity (stamped server-side) Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility + + // Version and Tag implement git-style revisioning. Version is bumped on + // every UpdateSignal; superseded revisions are kept as backup files. Tag is + // an optional human label for a revision (e.g. "restored from v3"). + Version int `json:"version,omitempty"` + Tag string `json:"tag,omitempty"` } // InputRef names one upstream signal used as input to the pipeline. diff --git a/internal/datasource/synthetic/synthetic.go b/internal/datasource/synthetic/synthetic.go index e6658b9..d53e2dd 100644 --- a/internal/datasource/synthetic/synthetic.go +++ b/internal/datasource/synthetic/synthetic.go @@ -263,6 +263,9 @@ func (s *Synthetic) AddSignal(def SignalDef) error { if def.Name == "" { return errors.New("signal name must not be empty") } + if def.Version < 1 { + def.Version = 1 + } rg, err := compileGraph(def) if err != nil { @@ -340,6 +343,16 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error { s.mu.Unlock() return datasource.ErrNotFound } + // Preserve the superseded revision as a backup and bump the version. + oldDef := old.def + if oldDef.Version < 1 { + oldDef.Version = 1 + } + if err := s.backupVersion(oldDef); err != nil { + s.mu.Unlock() + return fmt.Errorf("back up revision: %w", err) + } + def.Version = oldDef.Version + 1 if old.cancel != nil { old.cancel() } diff --git a/internal/datasource/synthetic/versions.go b/internal/datasource/synthetic/versions.go new file mode 100644 index 0000000..f5ffb5a --- /dev/null +++ b/internal/datasource/synthetic/versions.go @@ -0,0 +1,174 @@ +package synthetic + +import ( + "encoding/json" + "fmt" + "hash/fnv" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + "unicode" + + "github.com/uopi/uopi/internal/datasource" +) + +// VersionMeta describes a single persisted revision of a synthetic signal, +// mirroring storage.VersionMeta so the frontend treats every versioned document +// type uniformly. +type VersionMeta struct { + Version int `json:"version"` + Name string `json:"name"` + Tag string `json:"tag,omitempty"` + Current bool `json:"current"` + SavedAt time.Time `json:"savedAt"` +} + +func (s *Synthetic) versionsDir() string { + return filepath.Join(s.storePath, "synthetic_versions") +} + +// slugForFile maps an arbitrary signal name to a filesystem-safe stem. A short +// hash of the full name is appended so distinct names that sanitise to the same +// stem (e.g. "A:B" and "A_B") never share backup files. +func slugForFile(name string) string { + var b strings.Builder + for _, r := range name { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' { + b.WriteRune(r) + } else { + b.WriteByte('_') + } + } + h := fnv.New32a() + _, _ = h.Write([]byte(name)) + return fmt.Sprintf("%s-%08x", b.String(), h.Sum32()) +} + +func (s *Synthetic) versionPath(name string, version int) string { + return filepath.Join(s.versionsDir(), fmt.Sprintf("%s.v%d.json", slugForFile(name), version)) +} + +// backupVersion writes a single signal revision to the versions directory. +func (s *Synthetic) backupVersion(def SignalDef) error { + if err := os.MkdirAll(s.versionsDir(), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(def, "", " ") + if err != nil { + return err + } + return os.WriteFile(s.versionPath(def.Name, def.Version), data, 0o644) +} + +// Versions returns metadata for every persisted revision of the named signal, +// newest first. The live revision is flagged Current. +func (s *Synthetic) Versions(name string) ([]VersionMeta, error) { + cur, err := s.GetSignal(name) + if err != nil { + return nil, err + } + curV := cur.Version + if curV < 1 { + curV = 1 + } + + var savedAt time.Time + if info, err := os.Stat(s.defsFilePath()); err == nil { + savedAt = info.ModTime() + } + out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}} + + entries, err := os.ReadDir(s.versionsDir()) + if err != nil && !os.IsNotExist(err) { + return nil, err + } + prefix := slugForFile(name) + ".v" + for _, e := range entries { + fn := e.Name() + if e.IsDir() || !strings.HasPrefix(fn, prefix) || !strings.HasSuffix(fn, ".json") { + continue + } + vStr := strings.TrimSuffix(strings.TrimPrefix(fn, prefix), ".json") + v, err := strconv.Atoi(vStr) + if err != nil { + continue + } + def, err := s.readVersion(name, v) + if err != nil { + continue + } + info, err := e.Info() + if err != nil { + continue + } + out = append(out, VersionMeta{Version: v, Name: def.Name, Tag: def.Tag, SavedAt: info.ModTime()}) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version }) + return out, nil +} + +// GetVersion returns a specific revision of the named signal. The current +// revision comes from the live store; older revisions from their backup file. +func (s *Synthetic) GetVersion(name string, version int) (SignalDef, error) { + cur, err := s.GetSignal(name) + if err != nil { + return SignalDef{}, err + } + curV := cur.Version + if curV < 1 { + curV = 1 + } + if version == curV { + return cur, nil + } + return s.readVersion(name, version) +} + +func (s *Synthetic) readVersion(name string, version int) (SignalDef, error) { + data, err := os.ReadFile(s.versionPath(name, version)) + if os.IsNotExist(err) { + return SignalDef{}, datasource.ErrNotFound + } + if err != nil { + return SignalDef{}, err + } + var def SignalDef + if err := json.Unmarshal(data, &def); err != nil { + return SignalDef{}, fmt.Errorf("parse revision: %w", err) + } + return def, nil +} + +// PromoteVersion makes a past revision current by re-saving it on top of +// history (non-destructive). Returns the resulting current definition. +func (s *Synthetic) PromoteVersion(name string, version int) (SignalDef, error) { + def, err := s.GetVersion(name, version) + if err != nil { + return SignalDef{}, err + } + def.Tag = fmt.Sprintf("restored from v%d", version) + if err := s.UpdateSignal(def); err != nil { + return SignalDef{}, err + } + return s.GetSignal(name) +} + +// ForkVersion creates a brand-new synthetic signal from a specific revision, +// assigning a fresh unique name and resetting its version to 1. +func (s *Synthetic) ForkVersion(name string, version int) (SignalDef, error) { + def, err := s.GetVersion(name, version) + if err != nil { + return SignalDef{}, err + } + def.Name = fmt.Sprintf("%s_fork_%d", name, time.Now().UnixMilli()) + def.Version = 1 + def.Tag = "" + if err := s.AddSignal(def); err != nil { + return SignalDef{}, err + } + return def, nil +} diff --git a/internal/datasource/synthetic/versions_test.go b/internal/datasource/synthetic/versions_test.go new file mode 100644 index 0000000..ab95746 --- /dev/null +++ b/internal/datasource/synthetic/versions_test.go @@ -0,0 +1,87 @@ +package synthetic + +import ( + "context" + "testing" +) + +func TestSyntheticVersioning(t *testing.T) { + syn, _, cancel := newTestSynthetic(t) + defer cancel() + if err := syn.Connect(context.Background()); err != nil { + t.Fatal(err) + } + + // defWithGain builds a fresh def each time, mirroring how the REST handler + // decodes a new SignalDef per request (no aliasing of slices/maps). + defWithGain := func(g float64) SignalDef { + return SignalDef{Name: "wf", DS: "stub", Signal: "sine_1hz", + Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": g}}}} + } + + if err := syn.AddSignal(defWithGain(1.0)); err != nil { + t.Fatalf("AddSignal: %v", err) + } + + cur, err := syn.GetSignal("wf") + if err != nil || cur.Version != 1 { + t.Fatalf("after add: version=%d err=%v", cur.Version, err) + } + + // Two edits → v2, v3. + if err := syn.UpdateSignal(defWithGain(2.0)); err != nil { + t.Fatalf("UpdateSignal: %v", err) + } + if err := syn.UpdateSignal(defWithGain(3.0)); err != nil { + t.Fatalf("UpdateSignal: %v", err) + } + + cur, _ = syn.GetSignal("wf") + if cur.Version != 3 { + t.Fatalf("want current v3, got v%d", cur.Version) + } + + versions, err := syn.Versions("wf") + if err != nil { + t.Fatalf("Versions: %v", err) + } + if len(versions) != 3 { + t.Fatalf("want 3 versions, got %d", len(versions)) + } + if !versions[0].Current || versions[0].Version != 3 { + t.Errorf("newest should be current v3: %+v", versions[0]) + } + + // v1 backup retrievable with original gain. + v1, err := syn.GetVersion("wf", 1) + if err != nil { + t.Fatalf("GetVersion v1: %v", err) + } + if v1.Pipeline[0].Params["gain"] != 1.0 { + t.Errorf("v1 gain: want 1.0, got %v", v1.Pipeline[0].Params["gain"]) + } + + // Promote v1 → becomes v4 (non-destructive). + promoted, err := syn.PromoteVersion("wf", 1) + if err != nil { + t.Fatalf("Promote: %v", err) + } + if promoted.Version != 4 || promoted.Pipeline[0].Params["gain"] != 1.0 { + t.Errorf("promote: version=%d gain=%v", promoted.Version, promoted.Pipeline[0].Params["gain"]) + } + + // Fork v3 → fresh signal, version 1. + forked, err := syn.ForkVersion("wf", 3) + if err != nil { + t.Fatalf("Fork: %v", err) + } + if forked.Version != 1 || forked.Name == "wf" { + t.Errorf("fork: name=%q version=%d", forked.Name, forked.Version) + } + if forked.Pipeline[0].Params["gain"] != 3.0 { + t.Errorf("fork gain: want 3.0, got %v", forked.Pipeline[0].Params["gain"]) + } + if _, err := syn.GetSignal(forked.Name); err != nil { + t.Errorf("forked signal not registered: %v", err) + } +} diff --git a/web/src/Canvas.tsx b/web/src/Canvas.tsx index cab2497..4c15812 100644 --- a/web/src/Canvas.tsx +++ b/web/src/Canvas.tsx @@ -16,6 +16,7 @@ import Button from './widgets/Button'; import PlotWidget from './widgets/PlotWidget'; import ImageWidget from './widgets/ImageWidget'; import LinkWidget from './widgets/LinkWidget'; +import ConfigSelect from './widgets/ConfigSelect'; import ContextMenu from './ContextMenu'; import InfoPanel from './InfoPanel'; import SplitLayout from './SplitLayout'; @@ -34,6 +35,7 @@ const COMPONENTS: Record = { plot: PlotWidget, image: ImageWidget, link: LinkWidget, + configselect: ConfigSelect, }; interface CtxState { diff --git a/web/src/ConfigManager.tsx b/web/src/ConfigManager.tsx index d713aa5..8934229 100644 --- a/web/src/ConfigManager.tsx +++ b/web/src/ConfigManager.tsx @@ -1,6 +1,8 @@ import { h, Fragment } from 'preact'; import { useState, useEffect, useCallback, useRef } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; +import { VersionTree, DiffViewer } from './VersionHistory'; +import CueEditor from './CueEditor'; // ── Types (mirror internal/confmgr) ────────────────────────────────────────── @@ -44,8 +46,24 @@ interface ConfigInstance { values: Record; } -interface Meta { id: string; name: string; version: number; } -interface VersionMeta { version: number; name: string; tag?: string; current: boolean; savedAt: string; } +interface Meta { id: string; name: string; version: number; setId?: string; } + +// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation +// logic bound to a set, run when instances of that set are saved. +interface ConfigRule { + id: string; + name: string; + setId: string; + description?: string; + version: number; + tag?: string; + owner?: string; + source: string; +} + +// RuleViolation / RuleResult mirror the backend evaluation payload. +interface RuleViolation { rule?: string; path?: string; message: string; } +interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record; compileError?: string; } interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; } interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; } @@ -138,13 +156,6 @@ function splitRef(ref: string): { ds: string; signal: string } { return { ds: ref.slice(0, i), signal: ref.slice(i + 1) }; } -function fmtTime(iso: string): string { - const d = new Date(iso); - if (isNaN(d.getTime())) return iso; - const pad = (n: number) => String(n).padStart(2, '0'); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - // ── Top-level modal ─────────────────────────────────────────────────────────── interface Props { onClose: () => void; } @@ -154,7 +165,7 @@ interface Props { onClose: () => void; } type CloseGuard = (proceed: () => void) => void; export default function ConfigManager({ onClose }: Props) { - const [tab, setTab] = useState<'sets' | 'instances'>('sets'); + const [tab, setTab] = useState<'sets' | 'instances' | 'rules'>('sets'); const guardRef = useRef(null); const registerGuard = useCallback((fn: CloseGuard | null) => { guardRef.current = fn; }, []); @@ -162,7 +173,7 @@ export default function ConfigManager({ onClose }: Props) { // before discarding unsaved edits. const guarded = (proceed: () => void) => { if (guardRef.current) guardRef.current(proceed); else proceed(); }; const requestClose = () => guarded(onClose); - const switchTab = (t: 'sets' | 'instances') => { if (t !== tab) guarded(() => setTab(t)); }; + const switchTab = (t: 'sets' | 'instances' | 'rules') => { if (t !== tab) guarded(() => setTab(t)); }; return (
{ if (e.target === e.currentTarget) requestClose(); }}> @@ -178,9 +189,12 @@ export default function ConfigManager({ onClose }: Props) {
+
- {tab === 'sets' ? : } + {tab === 'sets' && } + {tab === 'instances' && } + {tab === 'rules' && }
); @@ -277,6 +291,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [diff, setDiff] = useState<{ title: string; entries: SetDiffEntry[] } | null>(null); + const [viewingVersion, setViewingVersion] = useState(null); + const [verReload, setVerReload] = useState(0); const { allOptions, openAll, signalMeta } = useSignals(); const { pending, setPending } = useCloseGuard(registerGuard, dirty); const fileRef = useRef(null); @@ -351,6 +367,8 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) const saved = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(working.id)}`, jsonPut(working)); hist.load(saved); setDirty(false); + setViewingVersion(null); + setVerReload(k => k + 1); await reload(); return true; } catch (err) { setError(`Save failed: ${msg(err)}`); return false; } @@ -393,6 +411,31 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) } catch (err) { setError(`Diff failed: ${msg(err)}`); } } + // Load a past revision into the editor (non-destructive; saving promotes it to + // a new revision). Viewing alone is not a change, so the editor stays clean. + async function viewVersion(version: number) { + if (!working) return; + if (dirty && !confirm('Discard unsaved changes to view this version?')) return; + setError(null); + try { + const s = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(working.id)}/versions/${version}`); + hist.load(s); + setViewingVersion(version); + setDirty(false); + } catch (err) { setError(`Load version failed: ${msg(err)}`); } + } + async function reloadCurrent() { + if (!working) return; + try { + const s = await apiJSON(`/api/v1/config/sets/${encodeURIComponent(working.id)}`); + hist.load(s); + setViewingVersion(null); + setDirty(false); + setVerReload(k => k + 1); + await reload(); + } catch (err) { setError(msg(err)); } + } + return (
@@ -439,9 +482,11 @@ function SetsManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) patch({ parameters: ps })} onPatchParam={patchParam} onRemoveParam={removeParam} /> - { reload(); select(id); }} - onPromoted={() => select(working.id)} + onPromoted={reloadCurrent} onCompare={showDiff} onError={setError} />
)} @@ -760,6 +805,9 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | const [applyResult, setApplyResult] = useState(null); const [diff, setDiff] = useState<{ title: string; entries: InstanceDiffEntry[] } | null>(null); const [showNew, setShowNew] = useState(false); + const [showSnap, setShowSnap] = useState(false); + const [viewingVersion, setViewingVersion] = useState(null); + const [verReload, setVerReload] = useState(0); const { pending, setPending } = useCloseGuard(registerGuard, dirty); function undo() { hist.undo(); setDirty(true); } @@ -817,6 +865,8 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | const saved = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(working.id)}`, jsonPut(working)); hist.load(saved); setDirty(false); + setViewingVersion(null); + setVerReload(k => k + 1); await reload(); return true; } catch (err) { setError(`Save failed: ${msg(err)}`); return false; } @@ -864,6 +914,65 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | } catch (err) { setError(`Diff failed: ${msg(err)}`); } } + // Compare the stored instance values against the current live signal values. + async function liveDiff() { + if (!working) return; + setBusy(true); setError(null); + try { + const entries = (await apiJSON(`/api/v1/config/instances/${encodeURIComponent(working.id)}/livediff`)) ?? []; + setDiff({ title: `${working.name}: stored → current`, entries }); + } catch (err) { setError(`Live diff failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + // Snapshot a set's current live signal values into a new instance. + async function snapshot(setId: string, name: string) { + setBusy(true); setError(null); + try { + const body = name ? { name } : {}; + const res = await apiJSON<{ instance: ConfigInstance; snapshot: { captured: number; failed: number } }>( + `/api/v1/config/sets/${encodeURIComponent(setId)}/snapshot`, jsonPost(body)); + await reload(); + if (res) { + await select(res.instance.id); + if (res.snapshot.failed > 0) { + setError(`Snapshot captured ${res.snapshot.captured}, ${res.snapshot.failed} signal(s) could not be read.`); + } + } + setShowSnap(false); + } catch (err) { setError(`Snapshot failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + // Load a past revision into the editor (non-destructive; saving promotes it to + // a new revision). Viewing alone is not a change, so the editor stays clean. + async function viewVersion(version: number) { + if (!working) return; + if (dirty && !confirm('Discard unsaved changes to view this version?')) return; + setError(null); setApplyResult(null); + try { + const inst = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(working.id)}/versions/${version}`); + const set = await loadSet(inst!.setId, inst!.setVersion); + hist.load(inst); + setBoundSet(set); + setViewingVersion(version); + setDirty(false); + } catch (err) { setError(`Load version failed: ${msg(err)}`); } + } + async function reloadCurrent() { + if (!working) return; + try { + const inst = await apiJSON(`/api/v1/config/instances/${encodeURIComponent(working.id)}`); + const set = await loadSet(inst!.setId, inst!.setVersion); + hist.load(inst); + setBoundSet(set); + setViewingVersion(null); + setDirty(false); + setVerReload(k => k + 1); + await reload(); + } catch (err) { setError(msg(err)); } + } + const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id; return ( @@ -871,6 +980,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
Instances +
{sets.length === 0 &&
Create a config set first.
} @@ -898,6 +1008,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | +
Based on set {setName(working.setId)}{working.setVersion ? ` (v${working.setVersion})` : ''}.
@@ -913,9 +1024,11 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | {applyResult && } - { reload(); select(id); }} - onPromoted={() => select(working.id)} + onPromoted={reloadCurrent} onCompare={showDiff} onError={setError} />
)} @@ -923,6 +1036,8 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | {showNew && setShowNew(false)} onCreate={createInstance} />} + {showSnap && setShowSnap(false)} onSnapshot={snapshot} />} + {diff && setDiff(null)}> } @@ -1144,6 +1259,38 @@ function NewInstanceDialog({ sets, busy, onCancel, onCreate }: { ); } +function SnapshotDialog({ sets, busy, onCancel, onSnapshot }: { + sets: Meta[]; busy: boolean; onCancel: () => void; onSnapshot: (setId: string, name: string) => void; +}) { + const [name, setName] = useState(''); + const [setId, setSetId] = useState(sets[0]?.id ?? ''); + return ( +
{ if (e.target === e.currentTarget) onCancel(); }}> +
+
Snapshot config set
+
+ + +
+
+ + setName((e.target as HTMLInputElement).value)} /> +
+

Reads the current live value of every target signal of the chosen set and stores + them as a new config instance.

+
+ + +
+
+
+ ); +} + function ApplyResultView({ result }: { result: ApplyResult }) { return (
@@ -1173,48 +1320,22 @@ function ApplyResultView({ result }: { result: ApplyResult }) { // ── Versions panel (shared) ─────────────────────────────────────────────────── -function VersionPanel({ kind, id, onForked, onPromoted, onCompare, onError }: { - kind: 'sets' | 'instances'; +function VersionPanel({ kind, id, viewing, dirty, reloadKey, onView, onForked, onPromoted, onCompare, onError }: { + kind: 'sets' | 'instances' | 'rules'; id: string; - onReload: () => void; + viewing: number | null; + dirty: boolean; + reloadKey: number; + onView: (version: number) => void; onForked: (newId: string) => void; - onPromoted: () => void; + onPromoted: (version: number) => void; onCompare: (av: number, bv: number) => void; onError: (m: string) => void; }) { - const [versions, setVersions] = useState([]); const [open, setOpen] = useState(false); - const [a, setA] = useState(null); - const [b, setB] = useState(null); - - const base = `/api/v1/config/${kind}`; - - const load = useCallback(async () => { - try { - const v = (await apiJSON(`${base}/${encodeURIComponent(id)}/versions`)) ?? []; - setVersions(v); - if (v.length >= 2) { setA(v[1].version); setB(v[0].version); } - else if (v.length === 1) { setA(v[0].version); setB(v[0].version); } - } catch (err) { onError(`Versions failed: ${msg(err)}`); } - }, [base, id, onError]); - - useEffect(() => { if (open) load(); }, [open, load]); - // Reset when switching object. - useEffect(() => { setOpen(false); setVersions([]); }, [id]); - - async function fork(version: number) { - try { - const obj = await apiJSON<{ id: string }>(`${base}/${encodeURIComponent(id)}/versions/${version}/fork`, { method: 'POST' }); - onForked(obj!.id); - } catch (err) { onError(`Fork failed: ${msg(err)}`); } - } - async function promote(version: number) { - if (!confirm(`Promote v${version} to a new current version?`)) return; - try { - await apiJSON(`${base}/${encodeURIComponent(id)}/versions/${version}/promote`, { method: 'POST' }); - onPromoted(); - } catch (err) { onError(`Promote failed: ${msg(err)}`); } - } + const base = `/api/v1/config/${kind}/${encodeURIComponent(id)}`; + // Collapse when switching object. + useEffect(() => { setOpen(false); }, [id]); return (
@@ -1223,29 +1344,16 @@ function VersionPanel({ kind, id, onForked, onPromoted, onCompare, onError }: { {open && (
- {versions.length === 0 &&
No versions.
} - {versions.map(v => ( -
- v{v.version}{v.current && current} - {v.tag || ''} - {fmtTime(v.savedAt)} - - {!v.current && } -
- ))} - {versions.length >= 2 && ( -
- Compare - - - - -
- )} +
)}
@@ -1314,6 +1422,310 @@ function InstanceDiffTable({ entries }: { entries: InstanceDiffEntry[] }) { ); } +// ── Rules manager (CUE validation/transformation) ────────────────────────────── + +// sampleValues builds a representative value map from a set's parameter +// defaults, used to live-evaluate a rule's constraints while editing. +function sampleValues(set: ConfigSet | null): Record { + const out: Record = {}; + if (!set) return out; + for (const p of set.parameters) { + if (p.default !== undefined && p.default !== null) out[p.key] = p.default; + } + return out; +} + +function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) { + const [rules, setRules] = useState([]); + const [sets, setSets] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const hist = useUndo(); + const working = hist.present; + const [boundSet, setBoundSet] = useState(null); + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [check, setCheck] = useState(null); + const [showNew, setShowNew] = useState(false); + const [viewingVersion, setViewingVersion] = useState(null); + const [verReload, setVerReload] = useState(0); + const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null); + const { pending, setPending } = useCloseGuard(registerGuard, dirty); + + function undo() { hist.undo(); setDirty(true); } + function redo() { hist.redo(); setDirty(true); } + useUndoKeys(undo, redo, hist.canUndo, hist.canRedo); + + const reload = useCallback(async () => { + try { + setRules((await apiJSON('/api/v1/config/rules')) ?? []); + setSets((await apiJSON('/api/v1/config/sets')) ?? []); + } catch (err) { setError(`Failed to load: ${msg(err)}`); } + }, []); + useEffect(() => { reload(); }, [reload]); + + async function loadSet(setId: string): Promise { + if (!setId) return null; + return apiJSON(`/api/v1/config/sets/${encodeURIComponent(setId)}`); + } + + async function select(id: string) { + if (dirty && !confirm('Discard unsaved changes?')) return; + setError(null); setCheck(null); + try { + const rule = await apiJSON(`/api/v1/config/rules/${encodeURIComponent(id)}`); + const set = await loadSet(rule!.setId); + setSelectedId(id); + hist.load(rule); + setBoundSet(set); + setDirty(false); + } catch (err) { setError(msg(err)); } + } + + async function createRule(name: string, setId: string) { + setBusy(true); setError(null); + try { + const body = { name, setId, source: '// CUE rule: constrain or derive parameter values.\n' }; + const created = await apiJSON('/api/v1/config/rules', jsonPost(body)); + const set = await loadSet(created!.setId); + await reload(); + setSelectedId(created!.id); + hist.load(created); + setBoundSet(set); + setDirty(false); + setShowNew(false); + } catch (err) { setError(`Create failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + async function save(): Promise { + if (!working) return false; + setBusy(true); setError(null); + try { + const saved = await apiJSON(`/api/v1/config/rules/${encodeURIComponent(working.id)}`, jsonPut(working)); + hist.load(saved); + setDirty(false); + setViewingVersion(null); + setVerReload(k => k + 1); + await reload(); + return true; + } catch (err) { setError(`Save failed: ${msg(err)}`); return false; } + finally { setBusy(false); } + } + + async function remove(id: string) { + if (!confirm('Delete this rule? A server-side copy is kept in trash.')) return; + setBusy(true); setError(null); + try { + await apiJSON(`/api/v1/config/rules/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (selectedId === id) { setSelectedId(null); hist.load(null); setBoundSet(null); setDirty(false); } + await reload(); + } catch (err) { setError(`Delete failed: ${msg(err)}`); } + finally { setBusy(false); } + } + + function patch(p: Partial) { + if (!working) return; + hist.commit({ ...working, ...p }); + setDirty(true); + } + + // Live-validate the working source against the bound set's sample values. The + // /check endpoint compiles the (unsaved) source and reports compile errors and + // constraint violations, debounced so each keystroke does not hit the server. + useEffect(() => { + if (!working) { setCheck(null); return; } + const handle = setTimeout(async () => { + try { + const res = await apiJSON('/api/v1/config/rules/check', + jsonPost({ source: working.source, values: sampleValues(boundSet) })); + setCheck(res); + } catch { /* transient; ignore */ } + }, 400); + return () => clearTimeout(handle); + }, [working?.source, boundSet]); + + async function viewVersion(version: number) { + if (!working) return; + if (dirty && !confirm('Discard unsaved changes to view this version?')) return; + setError(null); + try { + const rule = await apiJSON(`/api/v1/config/rules/${encodeURIComponent(working.id)}/versions/${version}`); + hist.load(rule); + setViewingVersion(version); + setDirty(false); + } catch (err) { setError(`Load version failed: ${msg(err)}`); } + } + async function reloadCurrent() { + if (!working) return; + try { + const rule = await apiJSON(`/api/v1/config/rules/${encodeURIComponent(working.id)}`); + hist.load(rule); + setViewingVersion(null); + setDirty(false); + setVerReload(k => k + 1); + await reload(); + } catch (err) { setError(msg(err)); } + } + + const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id; + // Parameter keys + target signals offered as CUE completions. + const completions = boundSet + ? boundSet.parameters.flatMap(p => [p.key, `${p.ds}:${p.signal}`]).filter(Boolean) + : []; + + return ( +
+
+
+ Rules + +
+ {sets.length === 0 &&
Create a config set first.
} + {sets.length > 0 && rules.length === 0 &&
No rules yet.
} + {rules.map(s => ( +
select(s.id)}> + {s.name || '(unnamed)'} + {s.setId && {setName(s.setId)}} + v{s.version} + +
+ ))} +
+ +
+ {error &&
{error}
} + {!working &&
Select a rule on the left, or create a new one. Rules run when instances of the bound set are saved.
} + {working && ( +
+
+ patch({ name: (e.target as HTMLInputElement).value })} /> + v{working.version} + {dirty && unsaved} + + + +
+ +
+
+ + +
+
+ + patch({ description: (e.target as HTMLInputElement).value })} /> +
+
+ +
CUE source
+
+ Regular fields whose key matches a parameter are unified with the instance value — constraints + (e.g. voltage: >=0 & <=24) validate it, and concrete derivations + (e.g. power: voltage * current) transform it. Use _helpers and + #Defs for intermediate values. Ctrl+Space for completions. +
+ patch({ source: v })} /> + + + + { reload(); select(id); }} + onPromoted={reloadCurrent} + onCompare={(av, bv) => setDiff({ av, bv })} onError={setError} /> +
+ )} +
+ + {showNew && setShowNew(false)} onCreate={createRule} />} + + {diff && working && ( + setDiff(null)} /> + )} + + {pending && ( + setPending(null)} + onDiscard={() => { const p = pending.proceed; setDirty(false); setPending(null); p(); }} + onSave={async () => { const ok = await save(); if (ok) { const p = pending.proceed; setPending(null); p(); } }} /> + )} +
+ ); +} + +// RuleCheckView renders the live evaluation outcome: compile errors, constraint +// violations, or a pass with any transformed sample values. +function RuleCheckView({ result }: { result: RuleResult | null }) { + if (!result) return null; + if (result.compileError) { + return
+ Compile error: {result.compileError} +
; + } + if (!result.ok) { + return ( +
+ {(result.violations ?? []).length} violation(s) against sample (default) values: +
    + {(result.violations ?? []).map((v, i) => ( +
  • {v.path ? {v.path} : null} {v.message}
  • + ))} +
+
+ ); + } + const transformed = result.transformed ? Object.entries(result.transformed) : []; + return ( +
+ ✓ Valid — sample (default) values pass. + {transformed.length > 0 && ( + + {' '}Derived: {transformed.map(([k, v]) => {k}={String(v)})} + + )} +
+ ); +} + +function NewRuleDialog({ sets, busy, onCancel, onCreate }: { + sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void; +}) { + const [name, setName] = useState('New rule'); + const [setId, setSetId] = useState(sets[0]?.id ?? ''); + return ( +
{ if (e.target === e.currentTarget) onCancel(); }}> +
+
New config rule
+
+ + setName((e.target as HTMLInputElement).value)} /> +
+
+ + +
+
+ + +
+
+
+ ); +} + // ── small helpers ───────────────────────────────────────────────────────────── function msg(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/web/src/ControlLogicEditor.tsx b/web/src/ControlLogicEditor.tsx index a5ee04e..e631067 100644 --- a/web/src/ControlLogicEditor.tsx +++ b/web/src/ControlLogicEditor.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from 'preact/hooks'; import SignalPicker, { SignalOption } from './SignalPicker'; import LuaEditor from './LuaEditor'; import { checkExpr } from './lib/expr'; +import { VersionTree, DiffViewer } from './VersionHistory'; // ── Types (mirror internal/controllogic/model.go) ──────────────────────────── @@ -19,7 +20,12 @@ type CLNodeKind = | 'action.delay' | 'action.log' | 'action.lua' - | 'action.dialog'; + | 'action.dialog' + | 'action.config.apply' + | 'action.config.read' + | 'action.config.write' + | 'action.config.create' + | 'action.config.snapshot'; interface CLNode { id: string; @@ -78,6 +84,11 @@ const PALETTE: PaletteEntry[] = [ { kind: 'action.log', label: 'Log', params: { expr: '', label: '' } }, { kind: 'action.lua', label: 'Lua script', params: { script: '-- get("ds:name") reads, set("ds:name", v) writes,\n-- log("msg") logs to the server.\n' } }, { kind: 'action.dialog', label: 'Dialog', params: { kind: 'info', title: '', message: '', users: '', groups: '', target: '' } }, + { kind: 'action.config.apply', label: 'Apply config', params: { instance: '' } }, + { kind: 'action.config.read', label: 'Read config', params: { instance: '', key: '', target: '' } }, + { kind: 'action.config.write', label: 'Write config', params: { instance: '', key: '', expr: '' } }, + { kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '' } }, + { kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '' } }, ]; const KIND_LABEL: Record = { @@ -94,6 +105,11 @@ const KIND_LABEL: Record = { 'action.log': 'Log', 'action.lua': 'Lua script', 'action.dialog': 'Dialog', + 'action.config.apply': 'Apply config', + 'action.config.read': 'Read config', + 'action.config.write': 'Write config', + 'action.config.create': 'Create config', + 'action.config.snapshot': 'Snapshot config', }; const THRESHOLD_OPS = ['>', '<', '>=', '<=', '==', '!='] as const; @@ -148,6 +164,10 @@ export default function ControlLogicEditor({ onClose }: Props) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [showCloseConfirm, setShowCloseConfirm] = useState(false); + const [showVersions, setShowVersions] = useState(false); + const [viewingVersion, setViewingVersion] = useState(null); + const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null); + const [verReload, setVerReload] = useState(0); // Closing with unsaved changes prompts instead of silently discarding them. function requestClose() { @@ -259,15 +279,34 @@ export default function ControlLogicEditor({ onClose }: Props) { setDirty(true); } + // Load a past revision's content into the editor as unsaved edits; saving it + // promotes it to a new current version (non-destructive). + async function viewVersion(version: number) { + if (!graph) return; + if (dirty && !confirm('Discard unsaved changes to load this version?')) return; + try { + const res = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}/versions/${version}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const g: CLGraph = await res.json(); + setGraph(g); + setViewingVersion(version); + setDirty(false); + } catch (err) { + setError(`Load version failed: ${err instanceof Error ? err.message : err}`); + } + } + return (
{ if (e.target === e.currentTarget) requestClose(); }}> -
+
Control logic Server-side flows — run continuously, independent of any panel.
{graph && dirty && unsaved} {graph && } + {graph && }
@@ -313,13 +352,36 @@ export default function ControlLogicEditor({ onClose }: Props) { Saving a disabled graph stops it; enabling + saving starts it.
- { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} /> +
+ { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} /> + {showVersions && ( +
+
Version history
+ { await reload(); setSelectedId(newId); const r = await fetch(`/api/v1/controllogic/${encodeURIComponent(newId)}`); if (r.ok) { setGraph(await r.json()); setViewingVersion(null); setDirty(false); } }} + onPromoted={async () => { await reload(); const r = await fetch(`/api/v1/controllogic/${encodeURIComponent(graph.id)}`); if (r.ok) { setGraph(await r.json()); setViewingVersion(null); setDirty(false); } setVerReload(k => k + 1); }} + onCompare={(av, bv) => setDiff({ av, bv })} + onError={setError} /> +
+ )} +
)}
+ {diff && graph && ( + setDiff(null)} /> + )} + {showCloseConfirm && (
{ if (e.target === e.currentTarget) setShowCloseConfirm(false); }}>
@@ -352,6 +414,8 @@ function FlowEditor({ graph, onChange }: { const [selectedWire, setSelectedWire] = useState(null); const [dataSources, setDataSources] = useState([]); const [dsSignals, setDsSignals] = useState>({}); + const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]); + const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]); const canvasRef = useRef(null); const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null); @@ -364,6 +428,14 @@ function FlowEditor({ graph, onChange }: { .then(r => r.ok ? r.json() : []) .then((ds: DataSource[]) => setDataSources(ds.map(d => d.name))) .catch(() => {}); + fetch('/api/v1/config/instances') + .then(r => r.ok ? r.json() : []) + .then((list: { id: string; name: string }[]) => setConfigInstances(list ?? [])) + .catch(() => {}); + fetch('/api/v1/config/sets') + .then(r => r.ok ? r.json() : []) + .then((list: { id: string; name: string }[]) => setConfigSets(list ?? [])) + .catch(() => {}); }, []); async function loadSignals(ds: string) { @@ -836,6 +908,124 @@ function FlowEditor({ graph, onChange }: { )} + {selected.kind === 'action.config.apply' && ( +
+ + +

Applies the instance's current values to every bound signal (like the + Apply button in the config manager). Each write is audited.

+
+ )} + + {selected.kind === 'action.config.read' && ( + +
+ + +
+
+ + patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} /> +
+
+ + patchParams(selected.id, { target: ref })} /> +

Reads the named parameter's value and writes it to this target. Only + numeric values are supported.

+
+
+ )} + + {selected.kind === 'action.config.write' && ( + +
+ + +
+
+ + patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} /> +
+
+ + patchParams(selected.id, { expr: (e.target as HTMLInputElement).value })} /> +

Evaluates the expression and stores it into the named parameter, creating a + new instance revision. Only numeric values are supported; the value is coerced to the + parameter's declared type.

+
+
+ )} + + {selected.kind === 'action.config.create' && ( + +
+ + +
+
+ + patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} /> +
+
+ + +

Creates a new instance for the chosen set, optionally seeded from an existing + instance's values. The new id is logged to the audit trail.

+
+
+ )} + + {selected.kind === 'action.config.snapshot' && ( + +
+ + +
+
+ + patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} /> +

Reads the current live value of every target signal of the set and stores + them as a new instance. The new id is logged to the audit trail.

+
+
+ )} + )} diff --git a/web/src/CueEditor.tsx b/web/src/CueEditor.tsx new file mode 100644 index 0000000..bc7c6d7 --- /dev/null +++ b/web/src/CueEditor.tsx @@ -0,0 +1,259 @@ +import { h } from 'preact'; +import { useRef, useState } from 'preact/hooks'; + +// ── Tokenizer ───────────────────────────────────────────────────────────────── + +type TT = 'kw' | 'type' | 'str' | 'cmt' | 'num' | 'attr' | 'text'; + +const CUE_KEYWORDS = new Set([ + 'package', 'import', 'for', 'in', 'if', 'let', 'true', 'false', 'null', +]); +const CUE_TYPES = new Set([ + 'number', 'int', 'float', 'string', 'bytes', 'bool', 'uint', 'rune', +]); + +// Identifiers that make sensible always-available completions. +const CUE_COMPLETION_WORDS = [...CUE_KEYWORDS, ...CUE_TYPES]; + +interface Tok { t: TT; s: string; } + +function isAlpha(c: string) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '#'; } +function isAlNum(c: string) { return isAlpha(c) || (c >= '0' && c <= '9'); } +function isDigit(c: string) { return c >= '0' && c <= '9'; } + +function tokenize(src: string): Tok[] { + const out: Tok[] = []; + let i = 0; + + while (i < src.length) { + const c = src[i]; + + // Line comment + if (c === '/' && src[i + 1] === '/') { + const end = src.indexOf('\n', i); + const stop = end < 0 ? src.length : end; + out.push({ t: 'cmt', s: src.slice(i, stop) }); + i = stop; + continue; + } + + // Multiline string """...""" or '''...''' + if ((c === '"' || c === "'") && src[i + 1] === c && src[i + 2] === c) { + const q = c + c + c; + const end = src.indexOf(q, i + 3); + const stop = end < 0 ? src.length : end + 3; + out.push({ t: 'str', s: src.slice(i, stop) }); + i = stop; + continue; + } + + // String "..." or '...' + if (c === '"' || c === "'") { + let j = i + 1; + while (j < src.length) { + if (src[j] === '\\') { j += 2; continue; } + if (src[j] === c || src[j] === '\n') { j++; break; } + j++; + } + out.push({ t: 'str', s: src.slice(i, j) }); + i = j; + continue; + } + + // Number + if (isDigit(c) || (c === '.' && isDigit(src[i + 1] ?? ''))) { + let j = i; + while (j < src.length && (isDigit(src[j]) || src[j] === '.' || src[j] === '_')) j++; + if (j < src.length && (src[j] === 'e' || src[j] === 'E')) { + j++; + if (src[j] === '+' || src[j] === '-') j++; + while (j < src.length && isDigit(src[j])) j++; + } + out.push({ t: 'num', s: src.slice(i, j) }); + i = j; + continue; + } + + // Identifier / keyword / field label + if (isAlpha(c)) { + let j = i; + while (j < src.length && isAlNum(src[j])) j++; + const word = src.slice(i, j); + // Peek past spaces for a ':' => this identifier is a field label. + let k = j; + while (k < src.length && (src[k] === ' ' || src[k] === '\t')) k++; + let t: TT = 'text'; + if (CUE_KEYWORDS.has(word)) t = 'kw'; + else if (CUE_TYPES.has(word)) t = 'type'; + else if (src[k] === ':') t = 'attr'; + out.push({ t, s: word }); + i = j; + continue; + } + + out.push({ t: 'text', s: c }); + i++; + } + + return out; +} + +function renderTokens(src: string) { + return tokenize(src).map((tok, idx) => + tok.t === 'text' + ? {tok.s} + : {tok.s} + ); +} + +// ── Caret coordinates (mirror technique) ────────────────────────────────────── + +function caretXY(ta: HTMLTextAreaElement): { left: number; top: number; lineHeight: number } { + const style = getComputedStyle(ta); + const div = document.createElement('div'); + const props = [ + 'boxSizing', 'width', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', + 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', + 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing', 'tabSize', + ] as const; + for (const p of props) (div.style as any)[p] = (style as any)[p]; + div.style.position = 'absolute'; + div.style.visibility = 'hidden'; + div.style.whiteSpace = 'pre-wrap'; + div.style.wordWrap = 'break-word'; + div.style.overflow = 'hidden'; + + div.textContent = ta.value.slice(0, ta.selectionStart); + const marker = document.createElement('span'); + marker.textContent = ta.value.slice(ta.selectionStart) || '.'; + div.appendChild(marker); + document.body.appendChild(div); + const lineHeight = parseFloat(style.lineHeight) || (parseFloat(style.fontSize) * 1.4); + const top = marker.offsetTop - ta.scrollTop; + const left = marker.offsetLeft - ta.scrollLeft; + document.body.removeChild(div); + return { left, top, lineHeight }; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface Props { + value: string; + onChange: (v: string) => void; + rows?: number; + /** Parameter/signal names offered alongside the CUE vocabulary. */ + completions?: string[]; +} + +interface AC { items: string[]; index: number; from: number; left: number; top: number; } + +export default function CueEditor({ value, onChange, rows = 14, completions = [] }: Props) { + const preRef = useRef(null); + const taRef = useRef(null); + const [ac, setAc] = useState(null); + + const vocab = [...new Set([...completions, ...CUE_COMPLETION_WORDS])]; + + function syncScroll(e: Event) { + const ta = e.target as HTMLTextAreaElement; + if (preRef.current) { + preRef.current.scrollTop = ta.scrollTop; + preRef.current.scrollLeft = ta.scrollLeft; + } + } + + function wordPrefix(ta: HTMLTextAreaElement): { word: string; from: number } { + const caret = ta.selectionStart; + let from = caret; + while (from > 0 && /[A-Za-z0-9_#]/.test(ta.value[from - 1])) from--; + return { word: ta.value.slice(from, caret), from }; + } + + function refreshAC(ta: HTMLTextAreaElement) { + const { word, from } = wordPrefix(ta); + if (word.length < 1) { setAc(null); return; } + const lower = word.toLowerCase(); + const items = vocab.filter((w) => w.toLowerCase().startsWith(lower) && w !== word).slice(0, 8); + if (items.length === 0) { setAc(null); return; } + const { left, top, lineHeight } = caretXY(ta); + const rect = ta.getBoundingClientRect(); + setAc({ items, index: 0, from, left: rect.left + left, top: rect.top + top + lineHeight }); + } + + function accept(item: string) { + const ta = taRef.current; + if (!ta || !ac) return; + const caret = ta.selectionStart; + const next = value.slice(0, ac.from) + item + value.slice(caret); + onChange(next); + setAc(null); + const pos = ac.from + item.length; + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(pos, pos); + }); + } + + function onKeyDown(e: KeyboardEvent) { + if (!ac) { + // Ctrl+Space forces the completion popup open. + if (e.key === ' ' && e.ctrlKey) { + e.preventDefault(); + refreshAC(e.target as HTMLTextAreaElement); + } + return; + } + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setAc({ ...ac, index: (ac.index + 1) % ac.items.length }); + break; + case 'ArrowUp': + e.preventDefault(); + setAc({ ...ac, index: (ac.index - 1 + ac.items.length) % ac.items.length }); + break; + case 'Enter': + case 'Tab': + e.preventDefault(); + accept(ac.items[ac.index]); + break; + case 'Escape': + e.preventDefault(); + setAc(null); + break; + } + } + + return ( +
+ +