Compare commits

..

2 Commits

Author SHA1 Message Date
Martino Ferrari 113e5a0fe8 Implemented confi snapshots 2026-06-21 23:50:03 +02:00
Martino Ferrari 04d31a15c4 Done config 2026-06-21 17:40:04 +02:00
54 changed files with 5815 additions and 318 deletions
+11 -1
View File
@@ -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 |
+45 -29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
+4
View File
@@ -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=
+183
View File
@@ -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
+2 -1
View File
@@ -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")
+321
View File
@@ -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))
}
+25
View File
@@ -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 {
+1
View File
@@ -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()
+31
View File
@@ -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",
+199
View File
@@ -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...)
}
+80
View File
@@ -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")
}
}
+54 -6
View File
@@ -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) {
+129
View File
@@ -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
}
}
+83
View File
@@ -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
View File
@@ -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 {
+70
View File
@@ -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(), "")
+234
View File
@@ -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)
}
}
+277 -1
View File
@@ -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 {
+2
View File
@@ -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"`
}
+46 -8
View File
@@ -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).
+142
View File
@@ -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
}
+70
View File
@@ -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()
}
+174
View File
@@ -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)
}
}
+2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+194 -4
View File
@@ -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>
)}
+259
View File
@@ -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>
);
}
+1
View File
@@ -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
View File
@@ -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)} />
)}
+167
View File
@@ -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">
+44
View File
@@ -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>
)}
+72
View File
@@ -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>}
+227
View File
@@ -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>
);
}
+99
View File
@@ -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;
}
+167
View File
@@ -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);
+6 -1
View File
@@ -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 {
+601
View File
@@ -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; }
+80
View File
@@ -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
}
@@ -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
View File
@@ -1,3 +1,2 @@
Starting iocInit
iocRun: All initialization complete
CAS: CA beacon send to 192.168.1.255:5065 ERROR: Network is unreachable