Compare commits
2 Commits
b0ac044035
...
113e5a0fe8
| Author | SHA1 | Date | |
|---|---|---|---|
| 113e5a0fe8 | |||
| 04d31a15c4 |
@@ -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 |
|
||||
|
||||
@@ -11,29 +11,40 @@
|
||||
- user can fork an existing config instance to create a new one
|
||||
- user can compare two instances: show unified diff or side by side diff
|
||||
- etc...
|
||||
- [ ] user can apply and save configuration instances from manager
|
||||
- [ ] in logic editor and control loop add nodes to read/write/create/apply config instances
|
||||
- [ ] support for all supported types (number, bools, enums, arrays, string etc)
|
||||
- [ ] ux elements for config set
|
||||
- the configuration editor should automatically get type and info from signal when possible
|
||||
- a slick tree drag and drop editor to order elements and organize in group and sub-groups
|
||||
- possibility to customise unit, max, min etc
|
||||
- [ ] ux elements for config instances:
|
||||
- [x] user can apply and save configuration instances from manager
|
||||
- [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)
|
||||
- [x] a slick tree drag and drop editor to order elements and organize in group and sub-groups
|
||||
- [x] possibility to customise unit, max, min etc
|
||||
- [x] export / import a config set as JSON
|
||||
- [x] full-screen config window
|
||||
- [x] ux elements for config instances:
|
||||
- automatically use the correct setting widget depending on the type (e.g. combo for enum, nubmer input for number):
|
||||
- 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
|
||||
@@ -42,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
|
||||
@@ -52,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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+95
-3
@@ -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 |
|
||||
|-------------|--------|
|
||||
|
||||
+86
-6
@@ -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 `<logic>` block.
|
||||
endpoints and for any change to a panel's `<logic>` 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 `<logic>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 <logic>…</logic> section of an
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,6 +41,7 @@ func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult {
|
||||
res.Entries = append(res.Entries, e)
|
||||
continue
|
||||
}
|
||||
v = p.normalize(v)
|
||||
e.Value = v
|
||||
if err := write(p.DS, p.Signal, v); err != nil {
|
||||
e.Error = err.Error()
|
||||
|
||||
@@ -51,6 +51,37 @@ func TestApplyUsesDefaultWhenNoValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyArrayNormalizes(t *testing.T) {
|
||||
set := ConfigSet{ID: "s", Name: "s", Parameters: []Parameter{
|
||||
{Key: "wf", DS: "d", Signal: "WF", Type: TypeFloatArray},
|
||||
}}
|
||||
// JSON decoding yields []any of float64 — apply must hand the datasource a []float64.
|
||||
inst := ConfigInstance{ID: "i", SetID: "s", Values: map[string]any{"wf": []any{1.0, 2.0, 3.0}}}
|
||||
var got any
|
||||
res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil })
|
||||
if res.Applied != 1 {
|
||||
t.Fatalf("applied=%d", res.Applied)
|
||||
}
|
||||
arr, ok := got.([]float64)
|
||||
if !ok || len(arr) != 3 || arr[0] != 1 || arr[2] != 3 {
|
||||
t.Fatalf("want []float64{1,2,3}, got %T %v", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayValidation(t *testing.T) {
|
||||
lo := 0.0
|
||||
p := Parameter{Key: "wf", DS: "d", Signal: "S", Type: TypeFloatArray, Min: &lo}
|
||||
if err := p.checkValue([]any{1.0, 2.0}); err != nil {
|
||||
t.Fatalf("valid array rejected: %v", err)
|
||||
}
|
||||
if err := p.checkValue([]any{1.0, -5.0}); err == nil {
|
||||
t.Fatal("expected out-of-range element to be rejected")
|
||||
}
|
||||
if err := p.checkValue("notarray"); err == nil {
|
||||
t.Fatal("expected non-array value to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRecordsFailures(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1", Name: "s",
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,17 @@ import (
|
||||
type ParamType string
|
||||
|
||||
const (
|
||||
TypeFloat ParamType = "float64"
|
||||
TypeInt ParamType = "int64"
|
||||
TypeBool ParamType = "bool"
|
||||
TypeString ParamType = "string"
|
||||
TypeEnum ParamType = "enum"
|
||||
TypeFloat ParamType = "float64"
|
||||
TypeInt ParamType = "int64"
|
||||
TypeBool ParamType = "bool"
|
||||
TypeString ParamType = "string"
|
||||
TypeEnum ParamType = "enum"
|
||||
TypeFloatArray ParamType = "float64[]" // waveform; value is a list of numbers
|
||||
)
|
||||
|
||||
func (t ParamType) valid() bool {
|
||||
switch t {
|
||||
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum:
|
||||
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -197,10 +198,57 @@ func (p Parameter) checkValue(v any) error {
|
||||
if !slices.Contains(p.EnumValues, s) {
|
||||
return fmt.Errorf("value %q is not an allowed enum value", s)
|
||||
}
|
||||
case TypeFloatArray:
|
||||
arr, err := toFloatArray(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, f := range arr {
|
||||
if p.Min != nil && f < *p.Min {
|
||||
return fmt.Errorf("element %d value %v below minimum %v", i, f, *p.Min)
|
||||
}
|
||||
if p.Max != nil && f > *p.Max {
|
||||
return fmt.Errorf("element %d value %v above maximum %v", i, f, *p.Max)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalize coerces a JSON-decoded value into the canonical Go type the
|
||||
// datasource write path expects. Array parameters become []float64 (JSON yields
|
||||
// []any); scalar values are returned unchanged.
|
||||
func (p Parameter) normalize(v any) any {
|
||||
if p.Type == TypeFloatArray {
|
||||
if arr, err := toFloatArray(v); err == nil {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// toFloatArray coerces a JSON-decoded array value to []float64. JSON
|
||||
// unmarshalling yields []any of float64, but a native []float64 is also
|
||||
// accepted.
|
||||
func toFloatArray(v any) ([]float64, error) {
|
||||
switch a := v.(type) {
|
||||
case []float64:
|
||||
return a, nil
|
||||
case []any:
|
||||
out := make([]float64, len(a))
|
||||
for i, e := range a {
|
||||
f, err := toFloat(e)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("element %d: %w", i, err)
|
||||
}
|
||||
out[i] = f
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("value %v is not an array", v)
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling
|
||||
// yields float64 for numbers, but values may also arrive as int or string.
|
||||
func toFloat(v any) (float64, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+138
-4
@@ -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 {
|
||||
|
||||
@@ -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(), "")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, any> = {
|
||||
plot: PlotWidget,
|
||||
image: ImageWidget,
|
||||
link: LinkWidget,
|
||||
configselect: ConfigSelect,
|
||||
};
|
||||
|
||||
interface CtxState {
|
||||
|
||||
+1072
-140
File diff suppressed because it is too large
Load Diff
@@ -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<CLNodeKind, string> = {
|
||||
@@ -94,6 +105,11 @@ const KIND_LABEL: Record<CLNodeKind, string> = {
|
||||
'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<string | null>(null);
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(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 (
|
||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
|
||||
<div class="cl-modal">
|
||||
<div class="cl-modal cl-modal-full">
|
||||
<header class="cl-header">
|
||||
<span class="cl-title">Control logic</span>
|
||||
<span class="hint cl-subtitle">Server-side flows — run continuously, independent of any panel.</span>
|
||||
<div class="cl-header-actions">
|
||||
{graph && dirty && <span class="cl-dirty">unsaved</span>}
|
||||
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
|
||||
{graph && <button class={`panel-btn${showVersions ? ' panel-btn-primary' : ''}`}
|
||||
onClick={() => setShowVersions(v => !v)}>History</button>}
|
||||
<button class="panel-btn" onClick={requestClose}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -313,13 +352,36 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
</label>
|
||||
<span class="hint">Saving a disabled graph stops it; enabling + saving starts it.</span>
|
||||
</div>
|
||||
<FlowEditor graph={graph}
|
||||
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
|
||||
<div class="cl-editor-main">
|
||||
<FlowEditor graph={graph}
|
||||
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
|
||||
{showVersions && (
|
||||
<div class="ver-pane">
|
||||
<div class="ver-pane-head">Version history</div>
|
||||
<VersionTree
|
||||
base={`/api/v1/controllogic/${encodeURIComponent(graph.id)}`}
|
||||
viewing={viewingVersion}
|
||||
dirty={dirty}
|
||||
reloadKey={verReload}
|
||||
onView={viewVersion}
|
||||
onForked={async (newId) => { 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} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diff && graph && (
|
||||
<DiffViewer base={`/api/v1/controllogic/${encodeURIComponent(graph.id)}`}
|
||||
av={diff.av} bv={diff.bv} title={`${graph.name} — v${diff.av} → v${diff.bv}`}
|
||||
onClose={() => setDiff(null)} />
|
||||
)}
|
||||
|
||||
{showCloseConfirm && (
|
||||
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowCloseConfirm(false); }}>
|
||||
<div class="cl-confirm">
|
||||
@@ -352,6 +414,8 @@ function FlowEditor({ graph, onChange }: {
|
||||
const [selectedWire, setSelectedWire] = useState<number | null>(null);
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
|
||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(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 }: {
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.apply' && (
|
||||
<div class="wizard-field">
|
||||
<label>Config instance</label>
|
||||
<select class="prop-select" value={selected.params.instance ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
<p class="hint">Applies the instance's current values to every bound signal (like the
|
||||
Apply button in the config manager). Each write is audited.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.read' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config instance</label>
|
||||
<select class="prop-select" value={selected.params.instance ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Parameter key</label>
|
||||
<input class="prop-input" value={selected.params.key ?? ''}
|
||||
placeholder="parameter key"
|
||||
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal / variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Reads the named parameter's value and writes it to this target. Only
|
||||
numeric values are supported.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.write' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config instance</label>
|
||||
<select class="prop-select" value={selected.params.instance ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { instance: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Parameter key</label>
|
||||
<input class="prop-input" value={selected.params.key ?? ''}
|
||||
placeholder="parameter key"
|
||||
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Value expression</label>
|
||||
<input class="prop-input" value={selected.params.expr ?? ''}
|
||||
placeholder="e.g. {ds:signal} * 2"
|
||||
onInput={(e) => patchParams(selected.id, { expr: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">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.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.create' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={selected.params.set ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>New instance name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="auto"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Copy values from</label>
|
||||
<select class="prop-select" value={selected.params.from ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { from: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">defaults (empty)</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
<p class="hint">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.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.snapshot' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={selected.params.set ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>New instance name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="(auto)"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
<p class="hint">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.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
? <span key={idx}>{tok.s}</span>
|
||||
: <span key={idx} class={`cue-${tok.t}`}>{tok.s}</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<HTMLPreElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [ac, setAc] = useState<AC | null>(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 (
|
||||
<div class="cue-editor" style={`height:${rows * 1.5}em;`}>
|
||||
<pre ref={preRef} class="cue-pre" aria-hidden="true">
|
||||
{renderTokens(value)}
|
||||
{'\n'}
|
||||
</pre>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
class="cue-textarea"
|
||||
value={value}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
onInput={(e) => { onChange((e.target as HTMLTextAreaElement).value); refreshAC(e.target as HTMLTextAreaElement); }}
|
||||
onKeyDown={onKeyDown}
|
||||
onScroll={syncScroll}
|
||||
onBlur={() => setTimeout(() => setAc(null), 120)}
|
||||
/>
|
||||
{ac && (
|
||||
<ul class="cue-ac" style={`left:${ac.left}px;top:${ac.top}px;`}>
|
||||
{ac.items.map((it, i) => (
|
||||
<li
|
||||
key={it}
|
||||
class={i === ac.index ? 'cue-ac-item active' : 'cue-ac-item'}
|
||||
onMouseDown={(e) => { e.preventDefault(); accept(it); }}
|
||||
>
|
||||
{it}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
|
||||
plot: [400, 200],
|
||||
image: [200, 150],
|
||||
link: [140, 50],
|
||||
configselect: [220, 50],
|
||||
};
|
||||
|
||||
// Widget types that support multiple signals (signal can be appended)
|
||||
|
||||
+41
-111
@@ -11,20 +11,13 @@ import PropertiesPane from './PropertiesPane';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||
|
||||
interface Props {
|
||||
initial: Interface | null;
|
||||
onDone: (iface: Interface | null) => void;
|
||||
}
|
||||
|
||||
interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function blankInterface(): Interface {
|
||||
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
|
||||
}
|
||||
@@ -83,6 +76,7 @@ const STATIC_WIDGET_TYPES = [
|
||||
{ type: 'button', label: 'Action Button' },
|
||||
{ type: 'image', label: 'Image' },
|
||||
{ type: 'link', label: 'Link' },
|
||||
{ type: 'configselect', label: 'Config Selector' },
|
||||
];
|
||||
|
||||
export default function EditMode({ initial, onDone }: Props) {
|
||||
@@ -105,8 +99,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
// History panel
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
const [tag, setTag] = useState('');
|
||||
// Bumped whenever the editor content is replaced wholesale (version load /
|
||||
// promote) to force a full canvas remount, so no stale widget state lingers.
|
||||
@@ -328,6 +323,7 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
options:
|
||||
type === 'textlabel' ? { label: 'Label' } :
|
||||
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
|
||||
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
|
||||
{},
|
||||
};
|
||||
handleWidgetsChange([...(iface.widgets || []), newWidget]);
|
||||
@@ -359,7 +355,8 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
setDirty(false);
|
||||
setTag('');
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
if (showHistory) loadVersions();
|
||||
setViewingVersion(null);
|
||||
setVerReload(k => k + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -370,26 +367,12 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
// ── Version history ────────────────────────────────────────────────────────
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!iface.id) { setVersions([]); return; }
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions`);
|
||||
if (!res.ok) throw new Error(`Load versions failed (${res.status})`);
|
||||
setVersions(await res.json());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [iface.id]);
|
||||
|
||||
const toggleHistory = useCallback(() => {
|
||||
setShowHistory(s => {
|
||||
if (!s) loadVersions();
|
||||
if (!s) setVerReload(k => k + 1);
|
||||
return !s;
|
||||
});
|
||||
}, [loadVersions]);
|
||||
}, []);
|
||||
|
||||
// Load a past revision into the editor non-destructively: the current id is
|
||||
// preserved, so saving the restored content creates a new revision on top.
|
||||
@@ -403,45 +386,26 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
restored.id = iface.id; // keep editing the same interface
|
||||
setIface(restored);
|
||||
setSelectedIds([]);
|
||||
setDirty(true);
|
||||
// Viewing a past revision is not itself a change — only subsequent edits
|
||||
// dirty the editor (saving then promotes it to a new revision).
|
||||
setDirty(false);
|
||||
setCanvasNonce(n => n + 1);
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
setViewingVersion(version);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty]);
|
||||
|
||||
// Edit (or clear) the label of an existing revision in place.
|
||||
const editVersionTag = useCallback(async (version: number, currentTag: string) => {
|
||||
// Reload the now-current interface content into the editor (after a promote,
|
||||
// which the VersionTree performs server-side).
|
||||
const reloadCurrentIface = useCallback(async () => {
|
||||
if (!iface.id) return;
|
||||
const next = prompt('Label for this version (leave empty to clear):', currentTag ?? '');
|
||||
if (next === null) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/tag?tag=${encodeURIComponent(next)}`,
|
||||
{ method: 'PUT' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Tag update failed (${res.status})`);
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, loadVersions]);
|
||||
|
||||
// Make a past revision the current one (saved as a new revision on top).
|
||||
const promoteVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
if (dirty && !confirm('You have unsaved changes that will be discarded. Set this version as current?')) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/promote`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Promote failed (${res.status})`);
|
||||
// Reload the now-current interface content into the editor.
|
||||
const cur = await fetch(`/api/v1/interfaces/${encodeURIComponent(iface.id)}`);
|
||||
if (!cur.ok) throw new Error(`Reload failed (${cur.status})`);
|
||||
const reloaded = parseInterface(await cur.text());
|
||||
setIface(reloaded);
|
||||
setSelectedIds([]);
|
||||
@@ -450,25 +414,9 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
setHistoryLen(0);
|
||||
setViewingVersion(null);
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
loadVersions();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [iface.id, dirty, loadVersions]);
|
||||
|
||||
// Fork a revision into a brand-new interface.
|
||||
const forkVersion = useCallback(async (version: number) => {
|
||||
if (!iface.id) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/v1/interfaces/${encodeURIComponent(iface.id)}/versions/${version}/fork`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) throw new Error(`Fork failed (${res.status})`);
|
||||
const json = await res.json();
|
||||
window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'));
|
||||
alert(`Forked into new interface "${json.id}". Open it from the interface list.`);
|
||||
setVerReload(k => k + 1);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -733,49 +681,31 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
Save snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
{historyLoading && <div class="history-empty">Loading…</div>}
|
||||
{!historyLoading && versions.length === 0 && (
|
||||
<div class="history-empty">No saved versions yet.</div>
|
||||
)}
|
||||
{!historyLoading && versions.map(v => (
|
||||
<div
|
||||
key={v.version}
|
||||
class={`history-item${v.current ? ' history-item-current' : ''}`}
|
||||
>
|
||||
<div class="history-item-main">
|
||||
<span class="history-version">v{v.version}</span>
|
||||
{v.tag && <span class="history-tag">{v.tag}</span>}
|
||||
{v.current && <span class="history-current-badge">current</span>}
|
||||
</div>
|
||||
<div class="history-item-meta">
|
||||
{new Date(v.savedAt).toLocaleString()}
|
||||
</div>
|
||||
<div class="history-item-actions">
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => loadVersion(v.version)} title="Load this version into the editor (does not change history)">
|
||||
Load
|
||||
</button>
|
||||
)}
|
||||
{!v.current && (
|
||||
<button class="history-action-btn" onClick={() => promoteVersion(v.version)} title="Make this version the current one">
|
||||
Set current
|
||||
</button>
|
||||
)}
|
||||
<button class="history-action-btn" onClick={() => forkVersion(v.version)} title="Create a new interface from this version">
|
||||
Fork
|
||||
</button>
|
||||
<button class="history-action-btn" onClick={() => editVersionTag(v.version, v.tag ?? '')} title="Edit this version's label">
|
||||
Label
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!iface.id ? (
|
||||
<div class="history-empty">Save the interface first to enable history.</div>
|
||||
) : (
|
||||
<VersionTree
|
||||
base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
|
||||
viewing={viewingVersion}
|
||||
dirty={dirty}
|
||||
canWrite={writable}
|
||||
reloadKey={verReload}
|
||||
onView={loadVersion}
|
||||
onForked={() => window.dispatchEvent(new CustomEvent('uopi:refresh-interfaces'))}
|
||||
onPromoted={reloadCurrentIface}
|
||||
onCompare={(av, bv) => setDiff({ av, bv })}
|
||||
onError={setError} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{diff && iface.id && (
|
||||
<DiffViewer base={`/api/v1/interfaces/${encodeURIComponent(iface.id)}`}
|
||||
av={diff.av} bv={diff.bv} title={`${iface.name} — v${diff.av} → v${diff.bv}`}
|
||||
onClose={() => setDiff(null)} />
|
||||
)}
|
||||
|
||||
{showHelp && (
|
||||
<HelpModal initialSection={helpSection} onClose={() => setShowHelp(false)} />
|
||||
)}
|
||||
|
||||
@@ -70,6 +70,11 @@ const PALETTE: PaletteEntry[] = [
|
||||
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
|
||||
{ kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } },
|
||||
{ kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } },
|
||||
{ kind: 'action.config.apply', label: 'Apply config', params: { instance: '', instanceSource: 'fixed', instanceVar: '' } },
|
||||
{ kind: 'action.config.read', label: 'Read config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', target: '' } },
|
||||
{ kind: 'action.config.write', label: 'Write config', params: { instance: '', instanceSource: 'fixed', instanceVar: '', key: '', expr: '' } },
|
||||
{ kind: 'action.config.create', label: 'Create config', params: { set: '', name: 'auto', from: '', target: '' } },
|
||||
{ kind: 'action.config.snapshot', label: 'Snapshot config', params: { set: '', name: '', target: '' } },
|
||||
];
|
||||
|
||||
const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
@@ -93,6 +98,11 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
|
||||
'action.dialog.info': 'Info dialog',
|
||||
'action.dialog.error': 'Error dialog',
|
||||
'action.dialog.setpoint': 'Set-point 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;
|
||||
@@ -140,6 +150,8 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
|
||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
|
||||
@@ -165,6 +177,14 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
.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) {
|
||||
@@ -194,6 +214,45 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
}
|
||||
function openAllSignals() { dsOptions.forEach(ds => loadSignals(ds)); }
|
||||
|
||||
// The config-instance selector shared by the apply/read/write nodes. The
|
||||
// instance can be fixed (chosen here at design time) or read at run time from
|
||||
// a panel variable (instanceSource='var') — this is how a config-selector
|
||||
// widget feeds these nodes the operator's current choice.
|
||||
function instanceField(node: LogicNode) {
|
||||
const src = node.params.instanceSource ?? 'fixed';
|
||||
return (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Instance source</label>
|
||||
<select class="prop-select" value={src}
|
||||
onChange={(e) => patchParams(node.id, { instanceSource: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="fixed">Fixed instance</option>
|
||||
<option value="var">From variable</option>
|
||||
</select>
|
||||
</div>
|
||||
{src === 'var' ? (
|
||||
<div class="wizard-field">
|
||||
<label>Instance-id variable</label>
|
||||
<SignalPicker value={node.params.instanceVar ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(node.id, { instanceVar: ref })} />
|
||||
<p class="hint">Reads the instance id (a string) from this panel variable — e.g. the
|
||||
output of a Config selector widget.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="wizard-field">
|
||||
<label>Config instance</label>
|
||||
<select class="prop-select" value={node.params.instance ?? ''}
|
||||
onChange={(e) => patchParams(node.id, { instance: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = nodes.find(n => n.id === selectedNode) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -637,6 +696,114 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.apply' && (
|
||||
<Fragment>
|
||||
{instanceField(selected)}
|
||||
<p class="hint">Applies the instance's current values to every bound signal (like the
|
||||
Apply button in the config manager).</p>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.read' && (
|
||||
<Fragment>
|
||||
{instanceField(selected)}
|
||||
<div class="wizard-field">
|
||||
<label>Parameter key</label>
|
||||
<input class="prop-input" value={selected.params.key ?? ''}
|
||||
placeholder="parameter key"
|
||||
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal / variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Reads the named parameter's value and writes it to this target
|
||||
(numeric values only).</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.write' && (
|
||||
<Fragment>
|
||||
{instanceField(selected)}
|
||||
<div class="wizard-field">
|
||||
<label>Parameter key</label>
|
||||
<input class="prop-input" value={selected.params.key ?? ''}
|
||||
placeholder="parameter key"
|
||||
onInput={(e) => patchParams(selected.id, { key: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
allSignals={allSignalOptions()} onOpenSignals={openAllSignals}
|
||||
hint="Stores the value into the named parameter, creating a new instance revision (numeric values only)." />
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.create' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={selected.params.set ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>New instance name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="auto"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Copy values from</label>
|
||||
<select class="prop-select" value={selected.params.from ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { from: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">defaults (empty)</option>
|
||||
{configInstances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Write new id to variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Creates a new instance for the chosen set (optionally seeded from another
|
||||
instance) and writes its id to this panel variable, ready to feed apply/read/write
|
||||
nodes set to "From variable".</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.config.snapshot' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
<label>Config set</label>
|
||||
<select class="prop-select" value={selected.params.set ?? ''}
|
||||
onChange={(e) => patchParams(selected.id, { set: (e.target as HTMLSelectElement).value })}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>New instance name</label>
|
||||
<input class="prop-input" value={selected.params.name ?? ''}
|
||||
placeholder="(auto)"
|
||||
onInput={(e) => patchParams(selected.id, { name: (e.target as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Write new id to variable</label>
|
||||
<SignalPicker value={selected.params.target ?? ''} options={allSignalOptions()}
|
||||
onOpen={openAllSignals} allowFreeform
|
||||
onChange={(ref) => patchParams(selected.id, { target: ref })} />
|
||||
<p class="hint">Reads the current live value of every target signal of the set and stores
|
||||
them as a new instance, then writes the new instance id to this panel variable.</p>
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
{selected.kind === 'action.accumulate' && (
|
||||
<Fragment>
|
||||
<div class="wizard-field">
|
||||
|
||||
@@ -55,6 +55,17 @@ const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
|
||||
|
||||
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
// Config sets are only needed by the Config Selector widget; fetch lazily the
|
||||
// first time one is selected.
|
||||
useEffect(() => {
|
||||
if (selected?.type !== 'configselect' || configSets.length > 0) return;
|
||||
fetch('/api/v1/config/sets')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((list: { id: string; name: string }[]) => setConfigSets(list ?? []))
|
||||
.catch(() => {});
|
||||
}, [selected?.type]);
|
||||
|
||||
function setOpt(key: string, value: string) {
|
||||
if (!selected) return;
|
||||
@@ -360,6 +371,39 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{w.type === 'configselect' && (
|
||||
<div>
|
||||
<Field label="Label">
|
||||
<TextInput value={w.options['label'] ?? 'Config'} onCommit={(v) => setOpt('label', v)} />
|
||||
</Field>
|
||||
<Field label="Config set">
|
||||
<select class="prop-select" value={w.options['set'] ?? ''}
|
||||
onChange={(e) => setOpt('set', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="">choose…</option>
|
||||
{configSets.map(cs => <option key={cs.id} value={cs.id}>{cs.name}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Output variable">
|
||||
<div class="prop-field-col">
|
||||
<select class="prop-input" value={w.options['output'] ?? ''}
|
||||
onChange={(e) => setOpt('output', (e.target as HTMLSelectElement).value)}>
|
||||
<option value="">(none)</option>
|
||||
{(iface.statevars ?? []).map(v => (
|
||||
<option key={v.name} value={v.name}>{v.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span class="prop-hint">A panel variable (define one of type "string" in the Variables tab); receives the selected instance id.</span>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Instance subset">
|
||||
<div class="prop-field-col">
|
||||
<TextInput value={w.options['instances'] ?? ''} onCommit={(v) => setOpt('instances', v)} />
|
||||
<span class="prop-hint">Comma-separated instance ids to show. Empty = all instances of the set.</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import SignalPicker, { SignalOption } from './SignalPicker';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
|
||||
import { inferNodeTypes, SynthType } from './lib/synthTypes';
|
||||
import { VersionTree, DiffViewer } from './VersionHistory';
|
||||
|
||||
interface DataSource { name: string; }
|
||||
interface SignalInfo { name: string; type?: string; }
|
||||
@@ -313,6 +314,11 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
// Quick-add HUD (S = signal, N = node); null when closed.
|
||||
const [hud, setHud] = useState<null | 'signal' | 'node'>(null);
|
||||
const [hudFilter, setHudFilter] = useState('');
|
||||
// Version history pane (existing signals only).
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
|
||||
const [diff, setDiff] = useState<{ av: number; bv: number } | null>(null);
|
||||
const [verReload, setVerReload] = useState(0);
|
||||
|
||||
// Identity fields — editable only when creating a new signal.
|
||||
const [newName, setNewName] = useState('');
|
||||
@@ -710,6 +716,44 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a fetched SignalDef to the editor (meta fields + rebuilt node graph).
|
||||
function applyDef(d: SignalDef, asEdit: boolean) {
|
||||
setDef(d);
|
||||
setUnit(d.meta?.unit ?? '');
|
||||
setDesc(d.meta?.description ?? '');
|
||||
setDispLow(String(d.meta?.displayLow ?? '0'));
|
||||
setDispHigh(String(d.meta?.displayHigh ?? '100'));
|
||||
const g = buildInitial(d);
|
||||
if (asEdit) commit(g); else { undoStack.current = []; redoStack.current = []; setGraph(g); bump(); }
|
||||
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
|
||||
}
|
||||
|
||||
// Load a past revision into the editor as an undoable edit; saving promotes it
|
||||
// to a new current version (non-destructive).
|
||||
async function viewVersion(version: number) {
|
||||
if (canUndo && !confirm('Discard unsaved changes to load this version?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(sigName)}/versions/${version}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const d: SignalDef = await res.json();
|
||||
applyDef(d, false);
|
||||
setViewingVersion(version);
|
||||
} catch (err) {
|
||||
setError(`Load version failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadCurrent() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(sigName)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
applyDef(await res.json(), false);
|
||||
setViewingVersion(null);
|
||||
} catch (err) {
|
||||
setError(`Reload failed: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeClass(n: GNode): string {
|
||||
const cat = n.kind === 'source' ? 'trigger' : n.kind === 'output' ? 'action' : 'flow';
|
||||
const err = nodeErrors.has(n.id) ? ' flow-node-error' : '';
|
||||
@@ -721,6 +765,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="wizard-header">
|
||||
<span>Synthetic Signal{create ? ' — new' : ` — ${name}`}</span>
|
||||
{!create && (
|
||||
<button class={`toolbar-btn${showVersions ? ' toolbar-btn-primary' : ''}`}
|
||||
style="margin-left:auto;" onClick={() => setShowVersions(v => !v)}>History</button>
|
||||
)}
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
@@ -982,6 +1030,30 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!create && showVersions && (
|
||||
<div class="ver-pane synth-ver-pane">
|
||||
<div class="ver-pane-head">Version history
|
||||
<button class="icon-btn" onClick={() => setShowVersions(false)}>✕</button>
|
||||
</div>
|
||||
<VersionTree
|
||||
base={`/api/v1/synthetic/${encodeURIComponent(sigName)}`}
|
||||
viewing={viewingVersion}
|
||||
dirty={canUndo}
|
||||
reloadKey={verReload}
|
||||
onView={viewVersion}
|
||||
onForked={() => { onSaved(); setVerReload(k => k + 1); }}
|
||||
onPromoted={async () => { await reloadCurrent(); onSaved(); setVerReload(k => k + 1); }}
|
||||
onCompare={(av, bv) => setDiff({ av, bv })}
|
||||
onError={setError} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diff && (
|
||||
<DiffViewer base={`/api/v1/synthetic/${encodeURIComponent(sigName)}`}
|
||||
av={diff.av} bv={diff.bv} title={`${sigName} — v${diff.av} → v${diff.bv}`}
|
||||
onClose={() => setDiff(null)} />
|
||||
)}
|
||||
|
||||
<div class="wizard-footer">
|
||||
{error && <span class="wizard-error" style="flex:1;">{error}</span>}
|
||||
{!error && firstError && <span class="hint" style="flex:1;">{firstError}</span>}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
import { diffLines, diffStats, sideBySide, DiffRow } from './lib/linediff';
|
||||
|
||||
// VersionMeta mirrors the Go VersionMeta returned by every versioned document
|
||||
// type (interfaces, synthetic, controllogic, config sets/instances).
|
||||
export interface VersionMeta {
|
||||
version: number;
|
||||
name: string;
|
||||
tag?: string;
|
||||
current: boolean;
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e); }
|
||||
|
||||
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())}`;
|
||||
}
|
||||
|
||||
async function fetchJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok && res.status !== 204) {
|
||||
let m = `HTTP ${res.status}`;
|
||||
try { const e = await res.json(); if (e && e.error) m = e.error; } catch { /* ignore */ }
|
||||
throw new Error(m);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// fetchVersionText returns a revision's serialized content as text, pretty-
|
||||
// printing JSON so structural changes produce clean, stable line diffs (XML
|
||||
// panels are returned verbatim).
|
||||
async function fetchVersionText(base: string, v: number): Promise<string> {
|
||||
const res = await fetch(`${base}/versions/${v}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const raw = await res.text();
|
||||
try { return JSON.stringify(JSON.parse(raw), null, 2); } catch { return raw; }
|
||||
}
|
||||
|
||||
// ── Version tree ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function VersionTree({ base, viewing, dirty, canWrite = true, onView, onForked, onPromoted, onCompare, onError, reloadKey }: {
|
||||
base: string; // e.g. /api/v1/synthetic/<name>
|
||||
viewing: number | null; // version currently open in the editor (active)
|
||||
dirty?: boolean; // unsaved edits → dashed connector at the top
|
||||
canWrite?: boolean; // gate promote/fork affordances
|
||||
onView: (version: number) => void;
|
||||
onForked: (newId: string) => void;
|
||||
onPromoted: (version: number) => void;
|
||||
onCompare: (av: number, bv: number) => void;
|
||||
onError: (m: string) => void;
|
||||
reloadKey?: number; // bump to force a versions reload
|
||||
}) {
|
||||
const [versions, setVersions] = useState<VersionMeta[]>([]);
|
||||
const [a, setA] = useState<number | null>(null);
|
||||
const [b, setB] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const v = (await fetchJSON<VersionMeta[]>(`${base}/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 (e) { onError(`Versions failed: ${errMsg(e)}`); }
|
||||
}, [base, onError]);
|
||||
|
||||
useEffect(() => { load(); }, [load, reloadKey]);
|
||||
|
||||
async function fork(version: number) {
|
||||
try {
|
||||
const obj = await fetchJSON<{ id: string }>(`${base}/versions/${version}/fork`, { method: 'POST' });
|
||||
if (obj?.id) onForked(obj.id);
|
||||
} catch (e) { onError(`Fork failed: ${errMsg(e)}`); }
|
||||
}
|
||||
async function promote(version: number) {
|
||||
if (!confirm(`Promote v${version} to a new current version?`)) return;
|
||||
try {
|
||||
await fetchJSON(`${base}/versions/${version}/promote`, { method: 'POST' });
|
||||
await load();
|
||||
onPromoted(version);
|
||||
} catch (e) { onError(`Promote failed: ${errMsg(e)}`); }
|
||||
}
|
||||
|
||||
const activeVer = viewing ?? versions.find(v => v.current)?.version ?? null;
|
||||
|
||||
return (
|
||||
<div class="ver-tree">
|
||||
{dirty && (
|
||||
<div class="ver-node ver-node-unsaved">
|
||||
<div class="ver-rail">
|
||||
<span class="ver-dot ver-dot-unsaved" />
|
||||
<span class="ver-line ver-line-dashed" />
|
||||
</div>
|
||||
<div class="ver-body"><span class="ver-num">unsaved changes</span></div>
|
||||
</div>
|
||||
)}
|
||||
{versions.length === 0 && <div class="hint ver-empty">No versions yet.</div>}
|
||||
{versions.map((v, idx) => {
|
||||
const active = v.version === activeVer;
|
||||
const last = idx === versions.length - 1;
|
||||
const cls = `ver-dot${v.current ? ' ver-dot-current' : ''}${active ? ' ver-dot-active' : ''}`;
|
||||
return (
|
||||
<div key={v.version} class={`ver-node${active ? ' ver-node-active' : ''}`}>
|
||||
<div class="ver-rail">
|
||||
<span class={cls} title={v.current ? 'Selected (executed) version' : ''} />
|
||||
{!last && <span class="ver-line" />}
|
||||
</div>
|
||||
<div class="ver-body">
|
||||
<button class="ver-num" title="View this version" onClick={() => onView(v.version)}>
|
||||
v{v.version}
|
||||
{v.current && <span class="ver-badge">current</span>}
|
||||
{active && !v.current && <span class="ver-badge ver-badge-view">viewing</span>}
|
||||
</button>
|
||||
{v.tag && <span class="ver-tag hint" title={v.tag}>{v.tag}</span>}
|
||||
<span class="ver-time hint">{fmtTime(v.savedAt)}</span>
|
||||
<span class="ver-actions">
|
||||
{activeVer !== null && v.version !== activeVer && (
|
||||
<button class="cl-mini-btn" title={`Diff against the viewed version (v${activeVer})`}
|
||||
onClick={() => onCompare(Math.min(activeVer, v.version), Math.max(activeVer, v.version))}>diff</button>
|
||||
)}
|
||||
{canWrite && <button class="cl-mini-btn" title="Fork into a new document" onClick={() => fork(v.version)}>fork</button>}
|
||||
{canWrite && !v.current && <button class="cl-mini-btn" title="Promote to current" onClick={() => promote(v.version)}>promote</button>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{versions.length >= 2 && (
|
||||
<div class="ver-compare">
|
||||
<span class="hint">Compare</span>
|
||||
<select class="prop-select" value={String(a ?? '')} onChange={(e) => setA(Number((e.target as HTMLSelectElement).value))}>
|
||||
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
|
||||
</select>
|
||||
<span>→</span>
|
||||
<select class="prop-select" value={String(b ?? '')} onChange={(e) => setB(Number((e.target as HTMLSelectElement).value))}>
|
||||
{versions.map(v => <option key={v.version} value={v.version}>v{v.version}</option>)}
|
||||
</select>
|
||||
<button class="panel-btn" disabled={a === null || b === null} onClick={() => onCompare(a!, b!)}>Diff</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Diff viewer ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function DiffViewer({ base, av, bv, title, onClose }: {
|
||||
base: string;
|
||||
av: number;
|
||||
bv: number;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<DiffRow[] | null>(null);
|
||||
const [mode, setMode] = useState<'unified' | 'split'>('split');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const [left, right] = await Promise.all([fetchVersionText(base, av), fetchVersionText(base, bv)]);
|
||||
if (alive) setRows(diffLines(left, right));
|
||||
} catch (e) { if (alive) setError(errMsg(e)); }
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [base, av, bv]);
|
||||
|
||||
const stats = rows ? diffStats(rows) : { added: 0, removed: 0 };
|
||||
|
||||
return (
|
||||
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="cl-confirm ver-diff-modal">
|
||||
<div class="ver-diff-head">
|
||||
<span class="cl-confirm-title">Diff — {title}</span>
|
||||
<span class="ver-diff-stats">
|
||||
<span class="ver-diff-add">+{stats.added}</span> <span class="ver-diff-del">−{stats.removed}</span>
|
||||
</span>
|
||||
<span class="ver-diff-modes">
|
||||
<button class={`panel-btn${mode === 'split' ? ' panel-btn-primary' : ''}`} onClick={() => setMode('split')}>Side by side</button>
|
||||
<button class={`panel-btn${mode === 'unified' ? ' panel-btn-primary' : ''}`} onClick={() => setMode('unified')}>Unified</button>
|
||||
</span>
|
||||
<button class="panel-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
{error && <div class="cl-error">{error}</div>}
|
||||
{!rows && !error && <div class="hint ver-diff-loading">Loading…</div>}
|
||||
{rows && (mode === 'unified' ? <UnifiedDiff rows={rows} /> : <SplitDiff rows={rows} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedDiff({ rows }: { rows: DiffRow[] }) {
|
||||
return (
|
||||
<div class="ver-diff ver-diff-unified">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} class={`ver-diff-row ver-diff-${r.op}`}>
|
||||
<span class="ver-diff-ln">{r.leftNo ?? ''}</span>
|
||||
<span class="ver-diff-ln">{r.rightNo ?? ''}</span>
|
||||
<span class="ver-diff-sign">{r.op === 'add' ? '+' : r.op === 'del' ? '−' : ' '}</span>
|
||||
<span class="ver-diff-text">{r.text || '\u00a0'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitDiff({ rows }: { rows: DiffRow[] }) {
|
||||
const pairs = sideBySide(rows);
|
||||
return (
|
||||
<div class="ver-diff ver-diff-split">
|
||||
{pairs.map((p, i) => (
|
||||
<div key={i} class="ver-diff-splitrow">
|
||||
<span class="ver-diff-ln">{p.left?.leftNo ?? ''}</span>
|
||||
<span class={`ver-diff-cell ver-diff-${p.left ? p.left.op : 'empty'}`}>{p.left ? (p.left.text || '\u00a0') : ''}</span>
|
||||
<span class="ver-diff-ln">{p.right?.rightNo ?? ''}</span>
|
||||
<span class={`ver-diff-cell ver-diff-${p.right ? p.right.op : 'empty'}`}>{p.right ? (p.right.text || '\u00a0') : ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Line-based diff (git style) used by the version history diff viewer. A simple
|
||||
// Longest-Common-Subsequence over lines yields a sequence of equal/added/removed
|
||||
// rows that can be rendered as a unified diff or split side-by-side.
|
||||
|
||||
export type DiffOp = 'equal' | 'add' | 'del';
|
||||
|
||||
export interface DiffRow {
|
||||
op: DiffOp;
|
||||
// Line content. For 'equal' both sides are identical (text).
|
||||
text: string;
|
||||
// 1-based line numbers in the left/right document; null when absent on a side.
|
||||
leftNo: number | null;
|
||||
rightNo: number | null;
|
||||
}
|
||||
|
||||
function splitLines(s: string): string[] {
|
||||
// Drop a single trailing newline so a file ending in "\n" doesn't yield a
|
||||
// spurious empty final row.
|
||||
const t = s.endsWith('\n') ? s.slice(0, -1) : s;
|
||||
return t.length === 0 ? [] : t.split('\n');
|
||||
}
|
||||
|
||||
// diffLines computes the LCS table and walks it back into ordered rows.
|
||||
export function diffLines(leftText: string, rightText: string): DiffRow[] {
|
||||
const a = splitLines(leftText);
|
||||
const b = splitLines(rightText);
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
|
||||
// lcs[i][j] = length of LCS of a[i:] and b[j:].
|
||||
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: DiffRow[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
let la = 1;
|
||||
let lb = 1;
|
||||
while (i < n && j < m) {
|
||||
if (a[i] === b[j]) {
|
||||
rows.push({ op: 'equal', text: a[i], leftNo: la++, rightNo: lb++ });
|
||||
i++; j++;
|
||||
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
||||
rows.push({ op: 'del', text: a[i], leftNo: la++, rightNo: null });
|
||||
i++;
|
||||
} else {
|
||||
rows.push({ op: 'add', text: b[j], leftNo: null, rightNo: lb++ });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < n) rows.push({ op: 'del', text: a[i++], leftNo: la++, rightNo: null });
|
||||
while (j < m) rows.push({ op: 'add', text: b[j++], leftNo: null, rightNo: lb++ });
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface DiffStats { added: number; removed: number; }
|
||||
|
||||
export function diffStats(rows: DiffRow[]): DiffStats {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const r of rows) {
|
||||
if (r.op === 'add') added++;
|
||||
else if (r.op === 'del') removed++;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
|
||||
// sideBySide pairs rows into aligned [left, right] columns: a del with the next
|
||||
// add becomes one changed row; otherwise each row occupies one side.
|
||||
export interface SideRow {
|
||||
left: DiffRow | null;
|
||||
right: DiffRow | null;
|
||||
}
|
||||
|
||||
export function sideBySide(rows: DiffRow[]): SideRow[] {
|
||||
const out: SideRow[] = [];
|
||||
for (let k = 0; k < rows.length; k++) {
|
||||
const r = rows[k];
|
||||
if (r.op === 'equal') {
|
||||
out.push({ left: r, right: r });
|
||||
} else if (r.op === 'del') {
|
||||
// Pair consecutive del/add as a single changed line when possible.
|
||||
const next = rows[k + 1];
|
||||
if (next && next.op === 'add') {
|
||||
out.push({ left: r, right: next });
|
||||
k++;
|
||||
} else {
|
||||
out.push({ left: r, right: null });
|
||||
}
|
||||
} else {
|
||||
out.push({ left: null, right: r });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -75,6 +75,88 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Reads a single parameter's value from a config instance, coercing it to a
|
||||
// number. Returns null if the instance/set/param is missing or non-numeric.
|
||||
async function readConfigParam(instanceId: string, key: string): Promise<number | null> {
|
||||
try {
|
||||
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
|
||||
if (!ir.ok) return null;
|
||||
const inst: { setId: string; setVersion?: number; values?: Record<string, any> } = await ir.json();
|
||||
const sv = inst.setVersion && inst.setVersion > 0
|
||||
? `/api/v1/config/sets/${encodeURIComponent(inst.setId)}/versions/${inst.setVersion}`
|
||||
: `/api/v1/config/sets/${encodeURIComponent(inst.setId)}`;
|
||||
const sr = await fetch(sv);
|
||||
if (!sr.ok) return null;
|
||||
const set: { parameters?: Array<{ key: string; default?: any }> } = await sr.json();
|
||||
const param = (set.parameters ?? []).find(p => p.key === key);
|
||||
if (!param) return null;
|
||||
const raw = inst.values?.[key] ?? param.default;
|
||||
const n = toNum(raw);
|
||||
return isNaN(n) ? null : n;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Write a single numeric value into a config instance's parameter, creating a
|
||||
// new revision server-side. Reads the current instance, overwrites values[key],
|
||||
// and PUTs it back. Silently no-ops on any transport/parse error.
|
||||
async function writeConfigParam(instanceId: string, key: string, val: number): Promise<void> {
|
||||
try {
|
||||
const ir = await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`);
|
||||
if (!ir.ok) return;
|
||||
const inst: any = await ir.json();
|
||||
inst.values = { ...(inst.values ?? {}), [key]: val };
|
||||
await fetch(`/api/v1/config/instances/${encodeURIComponent(instanceId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(inst),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Create a new config instance for `setId`, optionally seeding its values from
|
||||
// an existing instance `fromId`. Returns the new instance id, or '' on failure.
|
||||
async function createConfigInstance(setId: string, name: string, fromId: string): Promise<string> {
|
||||
try {
|
||||
let values: Record<string, any> = {};
|
||||
if (fromId) {
|
||||
const fr = await fetch(`/api/v1/config/instances/${encodeURIComponent(fromId)}`);
|
||||
if (fr.ok) {
|
||||
const src: any = await fr.json();
|
||||
values = { ...(src.values ?? {}) };
|
||||
}
|
||||
}
|
||||
const r = await fetch('/api/v1/config/instances', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, setId, values }),
|
||||
});
|
||||
if (!r.ok) return '';
|
||||
const out: any = await r.json();
|
||||
return String(out?.id ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the current live values of every target signal of `setId` into a new
|
||||
// config instance (optional label). Returns the new instance id, or '' on failure.
|
||||
async function snapshotConfigSet(setId: string, name: string): Promise<string> {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/config/sets/${encodeURIComponent(setId)}/snapshot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!r.ok) return '';
|
||||
const out: any = await r.json();
|
||||
return String(out?.instance?.id ?? '');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function toNum(v: any): number {
|
||||
if (typeof v === 'number') return v;
|
||||
if (typeof v === 'boolean') return v ? 1 : 0;
|
||||
@@ -158,6 +240,20 @@ class LogicEngine {
|
||||
return toNum(this.live.get(refKey(ds, name)));
|
||||
}
|
||||
|
||||
// Resolve the config-instance id a node operates on. With instanceSource
|
||||
// 'var' the id is read (as a raw string) from a panel-local variable / signal
|
||||
// — this is how a config-selector widget feeds the apply/read/write nodes.
|
||||
// Otherwise the fixed `instance` param (an id chosen at design time) is used.
|
||||
private resolveInstanceId(node: LogicNode): string {
|
||||
if ((node.params.instanceSource ?? 'fixed') === 'var') {
|
||||
const r = parseRef(node.params.instanceVar ?? '');
|
||||
if (!r) return '';
|
||||
const v = this.live.get(refKey(r.ds, r.name));
|
||||
return v == null ? '' : String(v).trim();
|
||||
}
|
||||
return (node.params.instance ?? '').trim();
|
||||
}
|
||||
|
||||
/** Activate a panel's logic graph. Replaces any previously loaded graph. */
|
||||
load(graph: LogicGraph | undefined): void {
|
||||
this.clear();
|
||||
@@ -266,6 +362,18 @@ class LogicEngine {
|
||||
case 'action.write':
|
||||
case 'action.accumulate':
|
||||
case 'action.log': collectRefs(node.params.expr ?? '').forEach(want); break;
|
||||
case 'action.config.apply':
|
||||
case 'action.config.read':
|
||||
case 'action.config.write': {
|
||||
// Dynamic-instance nodes read their target instance id from a panel
|
||||
// var; subscribe it so the live cache holds the selected id.
|
||||
if ((node.params.instanceSource ?? 'fixed') === 'var') {
|
||||
const r = parseRef(node.params.instanceVar ?? '');
|
||||
if (r) want(r);
|
||||
}
|
||||
if (node.kind === 'action.config.write') collectRefs(node.params.expr ?? '').forEach(want);
|
||||
break;
|
||||
}
|
||||
case 'action.dialog.info':
|
||||
case 'action.dialog.error':
|
||||
case 'action.dialog.setpoint': {
|
||||
@@ -390,6 +498,65 @@ class LogicEngine {
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.apply': {
|
||||
const id = this.resolveInstanceId(node);
|
||||
if (id) {
|
||||
try {
|
||||
await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
|
||||
} catch {}
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.read': {
|
||||
const id = this.resolveInstanceId(node);
|
||||
const key = (node.params.key ?? '').trim();
|
||||
const ref = parseRef(node.params.target ?? '');
|
||||
if (id && key && ref) {
|
||||
const val = await readConfigParam(id, key);
|
||||
if (val !== null && !isNaN(val)) wsClient.write(ref, val);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.write': {
|
||||
const id = this.resolveInstanceId(node);
|
||||
const key = (node.params.key ?? '').trim();
|
||||
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
|
||||
if (id && key && !isNaN(val)) {
|
||||
await writeConfigParam(id, key, val);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.create': {
|
||||
const setId = (node.params.set ?? '').trim();
|
||||
const name = (node.params.name ?? '').trim() || 'auto';
|
||||
const fromId = (node.params.from ?? '').trim();
|
||||
const target = parseRef(node.params.target ?? '');
|
||||
if (setId) {
|
||||
const newId = await createConfigInstance(setId, name, fromId);
|
||||
if (newId && target) wsClient.write(target, newId);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.config.snapshot': {
|
||||
const setId = (node.params.set ?? '').trim();
|
||||
const name = (node.params.name ?? '').trim();
|
||||
const target = parseRef(node.params.target ?? '');
|
||||
if (setId) {
|
||||
const newId = await snapshotConfigSet(setId, name);
|
||||
if (newId && target) wsClient.write(target, newId);
|
||||
}
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'action.delay':
|
||||
await sleep(Math.max(0, parseInt(node.params.ms ?? '0', 10) || 0));
|
||||
await this.follow(node.id, 'out', ctx);
|
||||
|
||||
@@ -152,7 +152,12 @@ export type LogicNodeKind =
|
||||
| 'action.widget'
|
||||
| 'action.dialog.info'
|
||||
| 'action.dialog.error'
|
||||
| 'action.dialog.setpoint';
|
||||
| 'action.dialog.setpoint'
|
||||
| 'action.config.apply'
|
||||
| 'action.config.read'
|
||||
| 'action.config.write'
|
||||
| 'action.config.create'
|
||||
| 'action.config.snapshot';
|
||||
|
||||
// A block on the flow canvas. `params` holds kind-specific fields as strings.
|
||||
export interface LogicNode {
|
||||
|
||||
@@ -888,6 +888,46 @@ body {
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Config selector widget — a labelled combo of a config set's instances whose
|
||||
choice is written to a panel-local variable. */
|
||||
.configselect {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cs-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cs-select {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
background: #0f1117;
|
||||
border: 1px solid #3d4f6e;
|
||||
border-radius: 4px;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cs-select:focus {
|
||||
outline: none;
|
||||
border-color: #4a9eff;
|
||||
}
|
||||
|
||||
.sv-btn:hover {
|
||||
background: #1d4ed8;
|
||||
}
|
||||
@@ -1084,10 +1124,14 @@ body {
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2d3748;
|
||||
border: none;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.2;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -2206,6 +2250,153 @@ body {
|
||||
.lua-cmt { color: #546e7a; font-style: italic; }
|
||||
.lua-num { color: #f78c6c; }
|
||||
|
||||
/* ── CUE editor (config rules) ───────────────────────────────────────────────── */
|
||||
.cue-editor {
|
||||
position: relative;
|
||||
border: 1px solid #3d4f6e;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: #0a0d14;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
min-height: 6em;
|
||||
}
|
||||
|
||||
.cue-pre,
|
||||
.cue-textarea {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
outline: none;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
tab-size: 2;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cue-pre {
|
||||
color: #e2e8f0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cue-textarea {
|
||||
color: transparent;
|
||||
caret-color: #e2e8f0;
|
||||
background: transparent;
|
||||
resize: none;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cue-textarea:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px #4a9eff;
|
||||
}
|
||||
|
||||
.cue-kw { color: #c792ea; font-weight: 600; }
|
||||
.cue-type { color: #82aaff; }
|
||||
.cue-attr { color: #ffcb6b; }
|
||||
.cue-str { color: #c3e88d; }
|
||||
.cue-cmt { color: #546e7a; font-style: italic; }
|
||||
.cue-num { color: #f78c6c; }
|
||||
|
||||
.cue-ac {
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
margin: 0;
|
||||
padding: 2px;
|
||||
list-style: none;
|
||||
background: #131826;
|
||||
border: 1px solid #3d4f6e;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
max-height: 14em;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
min-width: 8em;
|
||||
}
|
||||
|
||||
.cue-ac-item {
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
color: #cdd6e4;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cue-ac-item.active,
|
||||
.cue-ac-item:hover {
|
||||
background: #2a4d7a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Config rules ────────────────────────────────────────────────────────────── */
|
||||
.cfg-rule-set {
|
||||
font-size: 11px;
|
||||
opacity: 0.75;
|
||||
margin-left: auto;
|
||||
margin-right: 6px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 9em;
|
||||
}
|
||||
|
||||
.cfg-rule-help {
|
||||
margin: 4px 0 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cfg-rule-help code {
|
||||
background: #111726;
|
||||
border: 1px solid #2a3550;
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.cfg-rule-check {
|
||||
margin-top: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cfg-rule-check-ok {
|
||||
background: #11271a;
|
||||
border: 1px solid #2e6b46;
|
||||
color: #b6f0c8;
|
||||
}
|
||||
.cfg-rule-check-err {
|
||||
background: #2a1416;
|
||||
border: 1px solid #7a3038;
|
||||
color: #ffc7cd;
|
||||
}
|
||||
.cfg-rule-violations {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.cfg-rule-violations code,
|
||||
.cfg-rule-derived code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.cfg-rule-derived {
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* wizard is already wide enough and resizable — no extra :has() rule needed */
|
||||
|
||||
/* ── Synthetic pipeline ─────────────────────────────────────────────────────── */
|
||||
@@ -3495,6 +3686,13 @@ kbd {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cl-modal-full {
|
||||
width: 98vw;
|
||||
height: 96vh;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.cl-confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -3775,6 +3973,16 @@ kbd {
|
||||
|
||||
.cl-name-input { width: 240px; }
|
||||
|
||||
.cfg-desc {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
min-height: 2.4rem;
|
||||
max-height: 6.5rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cl-enable {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3984,6 +4192,131 @@ kbd {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.cfg-list-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Type is auto-derived from the bound signal — shown read-only. */
|
||||
.cfg-type-ro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 1.9rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px dashed #2d3748;
|
||||
border-radius: 4px;
|
||||
background: #11151f;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Parameter tree (group / subgroup organiser) ─────────────────────────── */
|
||||
.cfg-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.cfg-tree-toolbar {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.cfg-tree-group {
|
||||
border: 1px solid #232a3a;
|
||||
border-radius: 6px;
|
||||
background: #12161f;
|
||||
}
|
||||
.cfg-tree-ghead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: #1a2030;
|
||||
border-bottom: 1px solid #232a3a;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.cfg-tree-glabel {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
flex: 1;
|
||||
}
|
||||
.cfg-tree-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cfg-tree-ghead-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cfg-tree-sub {
|
||||
margin: 0.3rem 0 0.3rem 0.75rem;
|
||||
border-left: 2px solid #232a3a;
|
||||
}
|
||||
.cfg-tree-shead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.cfg-tree-sicon { color: #475569; }
|
||||
.cfg-tree-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.5rem 0.4rem;
|
||||
}
|
||||
.cfg-tree-param {
|
||||
border: 1px solid #232a3a;
|
||||
border-radius: 5px;
|
||||
background: #151a26;
|
||||
}
|
||||
.cfg-tree-param.dragging { opacity: 0.45; }
|
||||
.cfg-tree-param.drop-over { border-top: 2px solid #8b5cf6; }
|
||||
.cfg-tree-ghead.drop-over,
|
||||
.cfg-tree-shead.drop-over { outline: 2px dashed #8b5cf6; outline-offset: -2px; }
|
||||
.cfg-tree-phead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
cursor: grab;
|
||||
}
|
||||
.cfg-tree-phead:active { cursor: grabbing; }
|
||||
.cfg-drag-handle {
|
||||
color: #475569;
|
||||
cursor: grab;
|
||||
font-size: 0.9rem;
|
||||
user-select: none;
|
||||
}
|
||||
.cfg-tree-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.cfg-tree-pkey {
|
||||
font-family: var(--mono, monospace);
|
||||
font-size: 0.8rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.cfg-tree-plabel { font-size: 0.78rem; color: #94a3b8; }
|
||||
.cfg-tree-ptype {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #818cf8;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
.cfg-tree-psig { margin-left: auto; font-size: 0.72rem; }
|
||||
|
||||
/* ── Instance values ───────────────────────────────────────────────────── */
|
||||
.cfg-values {
|
||||
display: flex;
|
||||
@@ -4009,7 +4342,13 @@ kbd {
|
||||
}
|
||||
.cfg-req { color: #f87171; margin-left: 0.15rem; }
|
||||
.cfg-value-input { width: 10rem; flex: 0 0 auto; }
|
||||
/* Constrain the editing control to its column so it can't overflow and cover
|
||||
the adjacent signal-name label. */
|
||||
.cfg-value-input .prop-input,
|
||||
.cfg-value-input .prop-select { width: 100%; box-sizing: border-box; }
|
||||
.cfg-value-target {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 0.72rem;
|
||||
color: #64748b;
|
||||
overflow: hidden;
|
||||
@@ -4140,3 +4479,265 @@ kbd {
|
||||
.cfg-diff-changed { background: rgba(234, 179, 8, 0.08); }
|
||||
.cfg-diff-changed .cfg-diff-status { color: #facc15; }
|
||||
.cfg-diff-unchanged .cfg-diff-status { color: #64748b; }
|
||||
|
||||
/* ── Version history tree (slick git-style pane) ───────────────────────────── */
|
||||
.ver-tree {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.ver-empty { padding: 0.4rem 0; }
|
||||
.ver-node {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
/* The rail holds the circle and the connector line to the next node below. */
|
||||
.ver-rail {
|
||||
position: relative;
|
||||
flex: 0 0 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.ver-dot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 0.45rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #475569;
|
||||
background: #1a1f2e;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* Selected (the revision that is executed/shown) → filled. */
|
||||
.ver-dot-current { background: #2563eb; border-color: #2563eb; }
|
||||
/* Active (the revision currently viewed/edited) → larger. */
|
||||
.ver-dot-active {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 0.3rem;
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.ver-dot-unsaved { background: transparent; border-style: dashed; border-color: #f59e0b; }
|
||||
.ver-line {
|
||||
position: absolute;
|
||||
top: 0.45rem;
|
||||
bottom: -0.55rem;
|
||||
width: 2px;
|
||||
background: #2d3748;
|
||||
z-index: 0;
|
||||
}
|
||||
.ver-line-dashed {
|
||||
background: repeating-linear-gradient(to bottom, #f59e0b 0, #f59e0b 3px, transparent 3px, transparent 6px);
|
||||
}
|
||||
.ver-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex: 1;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid #1a2030;
|
||||
min-width: 0;
|
||||
}
|
||||
.ver-node-active .ver-body { background: rgba(96, 165, 250, 0.05); border-radius: 4px; }
|
||||
.ver-num {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
min-width: 2.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0;
|
||||
}
|
||||
.ver-num:hover { color: #fff; }
|
||||
.ver-badge {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
.ver-badge-view { background: #475569; }
|
||||
.ver-tag { color: #94a3b8; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ver-time { color: #64748b; font-size: 0.7rem; white-space: nowrap; }
|
||||
.ver-actions { display: flex; gap: 0.3rem; flex: 0 0 auto; }
|
||||
.ver-unsaved .ver-num, .ver-node-unsaved .ver-num { color: #f59e0b; font-weight: 500; font-style: italic; }
|
||||
.ver-compare {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.45rem;
|
||||
border-top: 1px solid #2d3748;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Version pane wrappers (embedded sidebars in the editors) ──────────────── */
|
||||
/* Control-logic editor: FlowEditor + history sidebar side by side. */
|
||||
.cl-editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cl-editor-main .flow-editor { flex: 1; min-width: 0; }
|
||||
.ver-pane {
|
||||
flex: 0 0 20rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-left: 1px solid #2d3748;
|
||||
background: #11151f;
|
||||
}
|
||||
.ver-pane-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-weight: 600;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
/* Synthetic editor: history floats over the right edge of the modal. */
|
||||
.synth-ver-pane {
|
||||
position: absolute;
|
||||
top: 3rem;
|
||||
right: 0;
|
||||
bottom: 3.2rem;
|
||||
z-index: 5;
|
||||
box-shadow: -6px 0 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* ── Version diff viewer (unified / side-by-side text diff) ────────────────── */
|
||||
.ver-diff-modal {
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 10px;
|
||||
width: min(1100px, 96vw);
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.ver-diff-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
font-size: 0.85rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.ver-diff-head .cl-confirm-title { flex: 1; }
|
||||
.ver-diff-stats { font-family: ui-monospace, monospace; font-size: 0.78rem; }
|
||||
.ver-diff-add { color: #4ade80; }
|
||||
.ver-diff-del { color: #f87171; }
|
||||
.ver-diff-modes { display: flex; gap: 0.3rem; }
|
||||
.ver-diff-loading { padding: 1rem; }
|
||||
.ver-diff {
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ver-diff-row { display: flex; white-space: pre; }
|
||||
.ver-diff-ln {
|
||||
flex: 0 0 3rem;
|
||||
text-align: right;
|
||||
padding: 0 0.5rem;
|
||||
color: #475569;
|
||||
user-select: none;
|
||||
background: #151a27;
|
||||
}
|
||||
.ver-diff-sign { flex: 0 0 1.2rem; text-align: center; color: #64748b; }
|
||||
.ver-diff-text { flex: 1; padding-right: 1rem; }
|
||||
.ver-diff-add { background: rgba(34, 197, 94, 0.12); }
|
||||
.ver-diff-del { background: rgba(239, 68, 68, 0.12); }
|
||||
.ver-diff-equal { color: #94a3b8; }
|
||||
.ver-diff-row.ver-diff-add .ver-diff-text { color: #bbf7d0; }
|
||||
.ver-diff-row.ver-diff-del .ver-diff-text { color: #fecaca; }
|
||||
/* Side-by-side grid */
|
||||
.ver-diff-splitrow { display: grid; grid-template-columns: 3rem 1fr 3rem 1fr; white-space: pre; }
|
||||
.ver-diff-cell { padding: 0 0.6rem; min-width: 0; overflow-x: hidden; }
|
||||
.ver-diff-cell.ver-diff-add { background: rgba(34, 197, 94, 0.12); color: #bbf7d0; }
|
||||
.ver-diff-cell.ver-diff-del { background: rgba(239, 68, 68, 0.12); color: #fecaca; }
|
||||
.ver-diff-cell.ver-diff-equal { color: #94a3b8; }
|
||||
.ver-diff-cell.ver-diff-empty { background: #141824; }
|
||||
|
||||
/* ── Config manager: array value editor ────────────────────────────────── */
|
||||
.cfg-value-input-array { width: auto; flex: 1; }
|
||||
.cfg-array { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.cfg-array-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.cfg-spark {
|
||||
background: #0f1117;
|
||||
border: 1px solid #232a3a;
|
||||
border-radius: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cfg-array-info { flex: 0 0 auto; min-width: 4rem; }
|
||||
|
||||
.cfg-array-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.4rem;
|
||||
background: #0f1117;
|
||||
border: 1px solid #232a3a;
|
||||
border-radius: 5px;
|
||||
max-height: 12rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.cfg-array-pt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.cfg-array-idx { width: 2rem; text-align: right; flex: 0 0 auto; }
|
||||
.cfg-array-pt .prop-input { width: 7rem; flex: 0 0 auto; }
|
||||
|
||||
.cfg-array-csv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem;
|
||||
background: #0f1117;
|
||||
border: 1px solid #232a3a;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.cfg-array-textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, monospace;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.cfg-array-plot-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.cfg-array-stats {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Invalid value highlighting */
|
||||
.cfg-value-invalid .cfg-value-label { color: #fca5a5; }
|
||||
.cfg-value-err { color: #f87171; cursor: help; margin-left: 0.25rem; }
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { wsClient } from '../lib/ws';
|
||||
import { getSignalStore } from '../lib/stores';
|
||||
import type { Widget, SignalValue } from '../lib/types';
|
||||
|
||||
interface InstanceMeta { id: string; name: string; setId?: string; }
|
||||
|
||||
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
|
||||
|
||||
// A combo that lists the config instances of a chosen set and writes the
|
||||
// selected instance id (a string) to a panel-local variable. The output var can
|
||||
// then drive the apply/read/write config logic nodes (set to "From variable"),
|
||||
// letting an operator pick which configuration the flow operates on at run time.
|
||||
export default function ConfigSelect({ widget, onContextMenu }: Props) {
|
||||
const setId = widget.options['set'] || '';
|
||||
const output = widget.options['output'] || '';
|
||||
const label = widget.options['label'] || 'Config';
|
||||
// Optional comma-separated allow-list of instance ids; empty shows them all.
|
||||
const subset = (widget.options['instances'] || '')
|
||||
.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const [instances, setInstances] = useState<InstanceMeta[]>([]);
|
||||
const [selected, setSelected] = useState('');
|
||||
|
||||
// Mirror the output var so external writers (e.g. a create-config node) keep
|
||||
// the combo in sync.
|
||||
useEffect(() => {
|
||||
if (!output) return;
|
||||
const unsub = getSignalStore({ ds: 'local', name: output }).subscribe((v: SignalValue) => {
|
||||
const id = v.value == null ? '' : String(v.value);
|
||||
setSelected(prev => (id && id !== prev ? id : prev));
|
||||
});
|
||||
return unsub;
|
||||
}, [output]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/v1/config/instances')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((list: InstanceMeta[]) => {
|
||||
if (cancelled) return;
|
||||
let filtered = (list ?? []).filter(ci => !setId || ci.setId === setId);
|
||||
if (subset.length > 0) filtered = filtered.filter(ci => subset.includes(ci.id));
|
||||
setInstances(filtered);
|
||||
// Default the output to the first instance when nothing is chosen yet.
|
||||
setSelected(prev => {
|
||||
if (prev && filtered.some(ci => ci.id === prev)) return prev;
|
||||
const first = filtered[0]?.id ?? '';
|
||||
if (first && output) wsClient.write({ ds: 'local', name: output }, first);
|
||||
return first;
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [setId, widget.options['instances']]);
|
||||
|
||||
function onChange(id: string) {
|
||||
setSelected(id);
|
||||
if (output) wsClient.write({ ds: 'local', name: output }, id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="configselect"
|
||||
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<span class="cs-label">{label}:</span>
|
||||
<select
|
||||
class="cs-select"
|
||||
value={selected}
|
||||
onChange={(e: Event) => onChange((e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{instances.length === 0 && <option value="">no instances</option>}
|
||||
{instances.map(ci => <option key={ci.id} value={ci.id}>{ci.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "new-instance",
|
||||
"name": "New instance",
|
||||
"owner": "martino",
|
||||
"setId": "new-config-set",
|
||||
"values": {
|
||||
"param1": 1,
|
||||
"param2": 1,
|
||||
"param3": "Test",
|
||||
"param4": "Idle"
|
||||
},
|
||||
"version": 3
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "new-instance",
|
||||
"name": "New instance",
|
||||
"owner": "martino",
|
||||
"setId": "new-config-set",
|
||||
"values": {},
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "new-instance",
|
||||
"name": "New instance",
|
||||
"owner": "martino",
|
||||
"setId": "new-config-set",
|
||||
"values": {
|
||||
"param1": 1,
|
||||
"param2": 1,
|
||||
"param4": "Idle"
|
||||
},
|
||||
"version": 2
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": "new-config-set",
|
||||
"name": "New config set",
|
||||
"owner": "martino",
|
||||
"parameters": [
|
||||
{
|
||||
"ds": "epics",
|
||||
"key": "param1",
|
||||
"max": 1,
|
||||
"min": 0,
|
||||
"signal": "UOPI:INTERLOCK",
|
||||
"type": "float64"
|
||||
},
|
||||
{
|
||||
"ds": "epics",
|
||||
"key": "param2",
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"signal": "UOPI:FLOW",
|
||||
"type": "float64",
|
||||
"unit": "L/min"
|
||||
},
|
||||
{
|
||||
"default": "CIAO",
|
||||
"ds": "epics",
|
||||
"key": "param3",
|
||||
"signal": "UOPI:MESSAGE",
|
||||
"subgroup": "Casa",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"ds": "epics",
|
||||
"enumValues": [
|
||||
"Idle",
|
||||
"Running",
|
||||
"Fault",
|
||||
"Maintenance"
|
||||
],
|
||||
"key": "param4",
|
||||
"signal": "UOPI:MODE",
|
||||
"subgroup": "Casa",
|
||||
"type": "enum"
|
||||
}
|
||||
],
|
||||
"version": 3
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "new-config-set",
|
||||
"name": "New config set",
|
||||
"owner": "martino",
|
||||
"parameters": [],
|
||||
"version": 1
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "new-config-set",
|
||||
"name": "New config set",
|
||||
"owner": "martino",
|
||||
"parameters": [
|
||||
{
|
||||
"ds": "epics",
|
||||
"key": "param1",
|
||||
"max": 1,
|
||||
"min": 0,
|
||||
"signal": "UOPI:INTERLOCK",
|
||||
"type": "float64"
|
||||
},
|
||||
{
|
||||
"ds": "epics",
|
||||
"key": "param2",
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
"signal": "UOPI:FLOW",
|
||||
"type": "float64",
|
||||
"unit": "L/min"
|
||||
}
|
||||
],
|
||||
"version": 2
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "flow_temperature_fork_1782058579992",
|
||||
"ds": "",
|
||||
"signal": "",
|
||||
"inputs": null,
|
||||
"pipeline": null,
|
||||
"graph": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "g_ina0z66",
|
||||
"kind": "output",
|
||||
"inputs": [
|
||||
"g_i8jh2kn"
|
||||
],
|
||||
"x": 759.75,
|
||||
"y": 137.15
|
||||
},
|
||||
{
|
||||
"id": "g_6iio15u",
|
||||
"kind": "source",
|
||||
"ds": "epics",
|
||||
"signal": "UOPI:TEMP",
|
||||
"x": 60.1,
|
||||
"y": 60.099999999999994
|
||||
},
|
||||
{
|
||||
"id": "g_i8jh2kn",
|
||||
"kind": "op",
|
||||
"op": "expr",
|
||||
"params": {
|
||||
"expr": "a * b",
|
||||
"vars": [
|
||||
"a",
|
||||
"b"
|
||||
]
|
||||
},
|
||||
"inputs": [
|
||||
"g_6iio15u",
|
||||
"g_v7p0lvm"
|
||||
],
|
||||
"x": 369.8666687011719,
|
||||
"y": 131.59834045410156
|
||||
},
|
||||
{
|
||||
"id": "g_v7p0lvm",
|
||||
"kind": "source",
|
||||
"ds": "epics",
|
||||
"signal": "UOPI:FLOW",
|
||||
"x": 56.099999999999994,
|
||||
"y": 170.35000000000002
|
||||
}
|
||||
],
|
||||
"output": "g_ina0z66"
|
||||
},
|
||||
"meta": {
|
||||
"displayHigh": 1000
|
||||
},
|
||||
"visibility": "user",
|
||||
"owner": "martino",
|
||||
"version": 1
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
Starting iocInit
|
||||
iocRun: All initialization complete
|
||||
CAS: CA beacon send to 192.168.1.255:5065 ERROR: Network is unreachable
|
||||
|
||||
Reference in New Issue
Block a user