Compare commits

...

35 Commits

Author SHA1 Message Date
Martino Ferrari 336095c052 plan updated 2026-06-26 14:08:28 +02:00
Martino Ferrari c53a49e540 controllogic: fix data race in setPath nested-array assignment
setPath mutated nested sub-arrays in place; since graph-local slices (and
their nested sub-slices) may be read concurrently by other flow goroutines,
an action.array.set with a multi-level path raced readers of the same local.
The single-threaded TS source mutates in place safely, but the Go port runs
flows on concurrent goroutines, so setPath now copies on descent (every level
returns a freshly allocated slice). Adds a -race regression test that
concurrently drives nested set + inner-element reads, plus a nested-set
correctness test asserting the prior stored value is not mutated.

Found by whole-branch review (Opus); missed by the per-task reviews.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 20:11:14 +02:00
Martino Ferrari b6bc9dc2f2 docs: control-logic array+scalar locals (Value model, statevars, array nodes) 2026-06-24 20:02:55 +02:00
Martino Ferrari 3133e50e09 controllogic: drop sign alias shim + add array node tests (set/remove/pop) 2026-06-24 20:02:52 +02:00
Martino Ferrari 550ec06bc8 ControlLogicEditor: local-var declarations + array action nodes 2026-06-24 19:56:28 +02:00
Martino Ferrari 9c1f2685a7 controllogic: debug value is Value (array-capable); lua get narrows to scalar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 19:50:47 +02:00
Martino Ferrari 088063d9cd controllogic: add action.array.* nodes (push/set/remove/pop/clear) 2026-06-24 19:45:40 +02:00
Martino Ferrari a6fa4e7c7c controllogic: locals as Value, init from declarations, value-aware write
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 19:40:29 +02:00
Martino Ferrari 519c1f2df4 controllogic: make expr evaluator value-polymorphic (arrays, indexing, array funcs)
Rewrites internal/controllogic/expr.go to evaluate to Value (scalar float64
or []Value array): adds array literals [a,b], postfix indexing arr[i], and
array functions (len, sum, mean, push, pop, slice, concat, sort, …). Resolver
type changes from func(...) float64 to func(...) Value; EvalValue added;
EvalExpr/EvalBool retain scalar float64/bool returns. Three temporary shims
added in engine.go, lua.go, and expr_test.go pending Tasks 4 and 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:53:40 +02:00
Martino Ferrari 3ddffc14d7 controllogic: add Graph.StateVars declarations (persisted via store JSON)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:46:01 +02:00
Martino Ferrari e76865d132 controllogic: add Value union + sizing helpers (port of arraypolicy.ts) 2026-06-24 14:42:40 +02:00
Martino Ferrari 5012511306 docs: plan for control-logic array+scalar local variables (Phase 2)
Implementation plan to port the panel-logic declared-local-variable feature
(scalar + array, sizing policies) to the server-side control-logic engine and
its editor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 14:40:35 +02:00
Martino Ferrari 44c8f98e01 Remove legacy array logic nodes; migrate + dropdown-ify
Delete the action.accumulate / action.clear node kinds (superseded by the
typed action.array.* nodes). Old panels are migrated transparently on load
(xml.ts migrateLegacyArrayNode): accumulate→array.push, clear→array.clear,
and legacy single-`array` export folded into the JSON `columns` form.

Export CSV column pickers now use array-local dropdowns instead of free-text
+ datalist, matching the action.array.* node inspectors. Removes the now-dead
flow-array-names datalist and legacy single-`array` fallbacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 14:21:11 +02:00
Martino Ferrari 05b06d64a4 feat(editor): suggest declared array locals in array-name autocomplete
The flow-array-names datalist (used by the export-CSV node columns and the
legacy accumulate/clear nodes) was sourced only from array names already
referenced by other nodes, so a freshly declared array local would not appear
as an autocomplete suggestion until it was typed somewhere. Seed the datalist
with declared array statevars first, then node-referenced names.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 14:08:11 +02:00
Martino Ferrari cb4e81beb2 fix(editor): array local-variable support in signal-tree pane
The signal-tree pane (edit mode) has its own inline "add local variable"
form, separate from the Logic-tab LocalVars form that gained array support
in the array-locals feature. This second entry point was missed, so the
type dropdown there only offered number/bool/string. Mirror the LogicEditor
form: add the array type option plus elem/sizing/capacity selects and a
JSON-validated initial value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 13:59:18 +02:00
Martino Ferrari 9d9e538a90 fix(logic): subscribe signals referenced by array action node params
subscribeRefs() walked node kinds to build the signal subscription set but
omitted the new action.array.push/set/remove nodes. Signal or local-var
references in their expr/index params were never subscribed, so the live
cache held no value and they resolved to NaN at run time (action.accumulate
was handled, its modern equivalent action.array.push was not). Add the
missing cases, including the comma-split index list for array.set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 13:50:26 +02:00
Martino Ferrari 9d48292976 docs(logic): document panel-logic array locals
Document array-valued local variables (statevar type=array with
elem/sizing/capacity), the value-polymorphic expression engine and array
functions, the array.* mutation nodes, accumulate/export unification, and
index-aligned CSV export in TECHNICAL_SPEC §3.8. Note in TODO that panel-logic
(Phase 1) array support is complete while the server-side control-logic port
(Phase 2) remains pending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 13:31:15 +02:00
Martino Ferrari 91661485ae feat(widgets): array source modes for plot, table, multi-LED
- PlotWidget: 1-D array values (local vars + EPICS waveforms) are
  routed through the waveform rendering path regardless of configured
  plotType; nested 2-D arrays are skipped gracefully.  effectivePlotType
  switches to 'waveform' whenever any waveforms[] slot is populated.
- TableWidget: new sourceMode:'array' option renders one <tr> per array
  element (index in 'name' col, formatted value in 'value' col).  For
  2-D (elem:'array') rows, colIndices option maps column positions to
  inner-array positions.  Scalar/multi-signal mode unchanged.
- MultiLed: new sourceMode:'array' option (or auto-detect when value is
  already an array) renders one LED per element with live-length tracking;
  elem truthiness controls lit state; per-element label/color overrides
  supported; meta.elem==='bool' awareness noted in code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 13:22:20 +02:00
Martino Ferrari 603574f86f feat(logic): array local declaration form + array nodes + array debug badges
- LocalVars: add array type option with elem/sizing/capacity sub-fields
  and JSON literal initial value with inline parse validation
- Palette + KIND_LABEL: register action.array.push/set/remove/pop/clear
- Inspector: array-node cases with array-local select, index and expr fields
- nodeLabel: compact summaries for all five array node kinds
- flowDebug: export fmtBadge() for compact array display [a,b,c,…](n=N);
  formatBadge now delegates to fmtBadge for numbers and arrays
- EditMode: wrap both initLocalState calls with ensureArrayDecls so
  array locals referenced only by logic nodes are auto-declared in preview

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 11:18:00 +02:00
Martino Ferrari b82e8852b3 feat(logic): round-trip array statevar attributes in panel XML
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 11:12:05 +02:00
Martino Ferrari 8f50bc2498 feat(logic): array action nodes + accumulate/export unification + migration
Add five action.array.push|set|remove|pop|clear node kinds; replace the
legacy {t,v} in-memory arrays store with array locals as single source of
truth; alias action.accumulate→push and action.clear→array.clear for
backward compat; rewrite action.export as index-aligned CSV with custom
header labels; add ensureArrayDecls() auto-declare migration wired via
Canvas.tsx so undeclared arrays referenced by logic nodes are initialised.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 11:07:24 +02:00
Martino Ferrari 062bb44dba feat(logic): array local init + sizing enforcement in localstate
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 11:01:32 +02:00
Martino Ferrari f776de378f fix(logic): single-eval array index + typed reduce accumulators
- Fix redundant double-evaluation of n.a in the 'index' case by extracting
  to a local const before use in both idx() call and array indexing.
- Add explicit <number> type argument to reduce() calls in sum and mean
  functions to fix TypeScript TS2345 accumulator type error: the inferred
  ArrVal union type was not compatible with numeric addition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:57:45 +02:00
Martino Ferrari 5578bceea2 feat(logic): array-aware expression engine (literals, indexing, array funcs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:54:43 +02:00
Martino Ferrari 2f40a9d1a0 feat(logic): add array sizing-policy helpers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:51:28 +02:00
Martino Ferrari 3b8c6540e1 feat(logic): add array fields to StateVar type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:50:06 +02:00
Martino Ferrari 774e28453b Add Phase 1 (panel-logic TS) implementation plan for local arrays
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:40:36 +02:00
Martino Ferrari e4e67ee0c2 Add design spec for local array values in flow engines
Approved design for first-class array-valued local variables across the
panel-logic (TS) and control-logic (Go) engines: array StateVar declarations
(dynamic/capped/fixed sizing), an array-aware expression language shared by
both engines, sizing-policy-aware mutation nodes (unifying the existing
accumulate/export/clear arrays), persistence, and widget binding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:32:11 +02:00
Martino Ferrari cf9da3df0a Implemented new datasources (modbus,scpi) 2026-06-24 06:12:52 +02:00
Martino Ferrari 999a1510d4 Ignore build/test output and workspace runtime state
Add .gitignore rules for SQLite WAL/SHM sidecars, coverage output, and
the dev workspace/data storage dir (keeping the demo.xml/epics_test.xml
fixtures), completing the runtime-artifact cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 01:48:07 +02:00
Martino Ferrari 6f7c90cc98 Stop tracking dev workspace runtime artifacts
Untrack generated runtime state under workspace/data (SQLite audit db +
WAL/SHM, config/control-logic/synthetic versions, trash, acl) plus dev
logs and iocsh history; these are regenerated on each run. Curated
fixtures (demo.xml, epics_test.xml, the EPICS test IOC db, run.sh,
st.cmd) stay tracked. Extend .gitignore so they don't return.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 01:47:43 +02:00
Martino Ferrari c0f7e662be Testing 2026-06-24 01:39:15 +02:00
Martino Ferrari 11120bedca Added ldap and pam authentication 2026-06-23 17:59:40 +02:00
Martino Ferrari ac24011487 Implemented admin pane + user permission 2026-06-22 17:49:14 +02:00
Martino Ferrari 73fcbe7b28 Improved plot view 2026-06-22 00:30:15 +02:00
168 changed files with 19679 additions and 1211 deletions
+13
View File
@@ -12,3 +12,16 @@ resources
.iocsh_history
go.work.sum
*.log
# Test output
coverage.out
*.coverprofile
# SQLite runtime databases (sidecar WAL/SHM)
*.db-shm
*.db-wal
# uopi dev runtime storage — ignore generated state, keep curated fixtures
workspace/data/*
!workspace/data/demo.xml
!workspace/data/epics_test.xml
+20 -1
View File
@@ -1,4 +1,4 @@
.PHONY: all frontend backend backend-debug release catools test bench race lint clean run
.PHONY: all frontend backend backend-debug backend-pam release catools test cover bench race lint fmt clean run
# --------------------------------------------------------------------------- #
# Sources — adding any file here triggers a frontend or backend rebuild #
@@ -51,6 +51,14 @@ $(CATOOLS): $(GO_SRCS)
@mkdir -p dist
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
# PAM-enabled binary: adds built-in HTTP Basic authentication validated against
# the host PAM stack (server.basic_auth). Requires cgo + libpam (-dev headers),
# so the resulting binary is NOT fully static — use only when basic_auth is needed.
backend-pam: $(FRONTEND_OUT)
@mkdir -p dist
CGO_ENABLED=1 go build -tags pam -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
# Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0)
backend-debug: $(FRONTEND_OUT)
@mkdir -p dist
@@ -81,11 +89,18 @@ release: $(FRONTEND_OUT)
test:
go test ./...
cd pkg/ca && go test ./...
cd pkg/pva && go test ./...
# Run tests with the race detector enabled.
race:
go test -race ./...
cd pkg/ca && go test -race ./...
cd pkg/pva && go test -race ./...
# Run the main module's tests with coverage and print the total.
cover:
go test -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -1
# Run all benchmarks and print memory allocations.
bench:
@@ -94,6 +109,10 @@ bench:
lint:
go vet ./...
# Rewrite all Go sources in canonical gofmt form (matches the CI gofmt gate).
fmt:
gofmt -w $(shell git ls-files '*.go')
run: $(BINARY)
$(BINARY)
+78 -23
View File
@@ -1,5 +1,8 @@
# TODO
- **BUG FIX**:
- [x] connecting from firefox always get default user — native SPNEGO/Kerberos auth (`[server.kerberos]`, `internal/server/kerberos.go`): uopi challenges with `401 Negotiate` and resolves the user from the validated ticket instead of relying on a proxy header Firefox never triggers; on non-Kerberos hosts (SSSD/LDAP) use standalone built-in HTTP Basic auth — either validated via PAM (`[server.basic_auth]`, `internal/pamauth`, build with `make backend-pam`) or, keeping the static binary, pure-Go LDAP search-then-bind (`[server.ldap]`, `internal/ldapauth`) — sharing `internal/server/basicauth.go` (which also challenges the page load so the browser shows its login dialog) with optional built-in TLS (`[server.tls]`)
- [x] dpi scaling seems not to work on firefox — root font-size now folds in `window.devicePixelRatio` (`applyZoom` = 16×zoom×dpr) with `watchDpr()` re-applying on monitor change
- [ ] **MAJOR** Implement configuration manager:
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
- with configuration manager user should be able to:
@@ -40,34 +43,65 @@
- [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:
- Config editor:
- [x] Instance should be filtered by config set (combo box) (`ConfigManager.tsx` InstancesManager: `setFilter` combo in the instance list head, shown when >1 set; empty-set hint)
- [x] Validation rules should also be filtered by config set (combo box) (`ConfigManager.tsx` RulesManager: `setFilter` combo mirroring instances, shown when >1 set; empty-set hint; `Meta.setId` surfaced via `List`)
- [x] Validation rules can be enabled / disabled — disabled rules are skipped when an instance is created/updated/applied (`Enabled *bool`, nil=enabled for legacy; `IsEnabled()`; `rulesForSet` skips disabled; UI checkbox + list `off` badge)
- [x] Rule editor preview button — runs the working (unsaved) source against a live signal snapshot of the set without persisting it, showing the input snapshot JSON and processed output JSON (`POST /config/rules/preview``previewConfigRule`; `RulePreviewView`)
- [ ] 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] color code the node link by type
- [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple)
- [x] input / outputs are color coded (out port = node's type; in port = accepted type, gray when it accepts either)
- [x] can not connect input / output that are not compatible (definite scalar↔array mismatch blocked; unknown/any always allowed)
- [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
- [ ] Panels:
- [x] in view mode the widgets should have no border/bg but blend with background
- [ ] 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 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
- make the toolbar as clean as possible
- [ ] Logic editor:
- add full support to local array values: dynamic, dynamic but capped max, fixed size etc:
- array functions should work with new local array
- [ ] Control loop:
- add full support to server side array values
- [x] toggle switch (`web/src/widgets/Toggle.tsx`; read+write bool control, configurable on/off value+label, confirm dialog)
- [x] table widget (`web/src/widgets/TableWidget.tsx`; multi-signal value table, one row per bound signal, configurable columns name/value/unit/status/time, optional header + title, per-signal value format + row-label overrides; blends in view mode)
- [ ] other industrial hmi widgets
- [x] widget panel exposes an editable Widget ID field (default = generated uuid) for easier logic interaction (`PropertiesPane.tsx` `IdInput`; `EditMode.renameWidgetId` propagates the rename to `action.widget` logic refs + plot-layout leaves; rejects empty/duplicate ids)
- [x] add container widgets: labelled/title pane, tab panes, collapsable panes (`web/src/widgets/Container.tsx`; decorative grouping frame rendered behind widgets, accent/bg options; `pane` variant = title + view-mode collapse; `tabs` variant = tab bar where each contained widget is assigned a tab via its "Tab" field and only the active tab shows; geometric membership via `web/src/lib/containers.ts`)
- [x] moving container widget should move the widgets on it — starting a move (drag or arrow nudge) on a container snapshots the widgets geometrically inside it (centre-inside, transitive through nested containers) and moves them by the same delta; membership is captured at move start so widgets never re-parent mid-move (`withContainedWidgets` in `web/src/lib/containers.ts`, used by `EditCanvas` drag-start + `EditMode` arrow nudge)
- [x] plot pane:
- [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`)
- [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types)
- [x] plot x-axis synchronised (shared uPlot cursor crosshair across all linked timeseries plots + zoom/pan range sync in historical mode; per-plot 🔗 link/unlink toolbar toggle; `web/src/lib/plotSync.ts`)
- [x] cursors and measuraments (📏 measure mode: click cursor A then B → on-canvas A/B lines + readout panel with Δt/rate and per-signal value@A→value@B and Δ)
- [x] pause/resume (local toolbar pause, OR'd with panel-logic pause; buffers persist across plot-type/window changes)
- [x] save screenshot (PNG export — uPlot canvas / ECharts getDataURL)
- [x] clean ui:
- [x] create small statusbar where connection widget and other status related info will be placed (bottom `.statusbar`: connection chip + current panel name + history indicator)
- [x] group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar (⋯ Tools ▾ dropdown)
- [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit)
- [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `<li>`, action buttons `stopPropagation` (`InterfaceList.tsx`)
- [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer)
- [x] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config). Uniform scope model: each item carries owner + scope ∈ {private,group,global} + scope-groups; empty/unknown scope = global (legacy-safe). Shared Go helper `access.CanSee` (`internal/access/scope.go`) filters list endpoints by the caller; shared frontend `web/src/lib/scope.tsx` provides `bucketOf`/`filterByScope`, the segmented `[Mine | Group ▾ | Global]` `ScopeFilter`, and the `ScopePicker` create/save visibility editor. Visibility is a selector filter, not a hard security boundary (owner always sees own items)
- [x] panel tree by user / group / global — scope bucket derived server-side from the panel ACL (`panelScope` in `internal/api/api.go`: public→global, group grant→group, else→private; unmanaged→global), surfaced on `InterfaceListItem.scope`/`groups`; `InterfaceList.tsx` filters panels by bucket and hides folders with no in-scope descendants; visibility is edited via the existing Share dialog
- [x] signal tree by user / group / global — synthetic `SignalDef` gained a `group` visibility mode + `Groups[]`; `synVisible` (`api.go`) routes it through `access.CanSee`; `SyntheticGraphEditor.tsx` wizard offers panel/user/group/global with group checkboxes
- [x] config tree by user / group / global — `ConfigSet`/`ConfigInstance` carry owner+scope+groups (`internal/confmgr`), list endpoints filter via `filterConfigMetas`/`CanSee`, owner preserved across updates; `ConfigManager.tsx` adds `ScopeFilter` + `ScopePicker` to both Sets and Instances managers
- [x] control sequence by user / group / global — control-logic `Graph` gained owner+scope+scopeGroups (`internal/controllogic/model.go`; named `scopeGroups` to avoid the existing cosmetic `Groups []NodeGroup`); `listControlLogic` filters via `CanSee`, create/update stamp/preserve owner; `ControlLogicEditor.tsx` adds `ScopeFilter` + `ScopePicker` (`filterByScope(..., g => g.scopeGroups ?? [])`)
- [ ] Node editors:
- [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select → G/⊞ to group; editable label; ▾/▸ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs — cosmetic editor metadata, ignored at eval)
- [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors)
- [x] node counter should be better padded (`.flow-group-count` gained vertical padding so the "N nodes" line isn't cramped against the collapsed-box header)
- [x] In all editors: undo / redo + copy / paste with shortcuts (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo-redo; Ctrl+C / Ctrl+V copy-paste of the selection, multi-select aware, internal wires preserved, ids remapped, pasted +offset; ref-based 50-deep history + clipboard scoped per editor; toolbar ↩/↪ buttons). Panel `LogicEditor` already had this; added to `ControlLogicEditor` (undo/redo + copy/paste) and `SyntheticGraphEditor` (copy/paste — it already had undo/redo). Synthetic paste never copies the permanent output node
- [x] In all editors: implement zoom in / out, home zoom and fit zoom to help user navigate complex graph (shared `web/src/lib/flowZoom.ts` `useFlowZoom` hook; CSS `transform: scale()` on a `.flow-canvas-zoom` layer inside the scaled `.flow-canvas-inner`; toolbar row /%//⤢ in all three editors; `toCanvas` divides pointer offsets by zoom)
- [x] fix: the fit-zoom (⤢) button was clipped outside the fixed-width palette — toolbar buttons now shrink to fit (`min-width:0`, zero side padding in `.flow-palette-toolbar .toolbar-btn`)
- [x] fix: zoom value (e.g. 100%) is overflowing, reduce padding and increase the zoom value widget width (`flow-zoom-pct` class on the zoom-value button → `flex:1.9` so it gets ~2× the width of the single-glyph icon buttons; toolbar font dropped to 0.75rem, gap to 0.25rem, `tabular-nums` to stop width jitter; all three editors)
- [x] Live / debug mode in all three node editors — evaluate the graph online and badge each node's value at human speed (~0.5 s). Shared frontend `web/src/lib/flowDebug.ts` (badge/active classes, `useFlowDebug` poller) + CSS `.flow-node-active`/`.flow-node-badge`/`.flow-wire-active`; a green ◉ toggle in each `.flow-palette-toolbar`. Per-editor backends:
- [ ] Syntetic signal editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server-side stateless single-shot trace: `POST /api/v1/synthetic/trace` snapshots live source signals (broker `ReadNow`) and runs `evalSampleTrace` with fresh state, returning every node's value; stateful ops (moving_average/rms/lowpass/derivative/integrate/lua) badged `~approx`
- [x] Logic editor:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — editor spins up a second client-side `LogicEngine` in new `dryRun` mode (real writes/config/dialogs suppressed) loaded with the edited graph; per-node values polled into the shared badges; the view-mode singleton is untouched
- [x] add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
- [x] array functions should work with new local array
- NOTE: **Phase 1 (panel logic, client-side TS) is DONE** — array statevars (dynamic/capped/fixed), value-polymorphic expression engine with indexing + array functions, `action.array.*` mutation nodes (legacy accumulate/export/clear unified + auto-migrated), panel-XML round-trip, editor declaration form, and widget array modes (plot/table/multi-LED). **Phase 2 (server-side `internal/controllogic` Go port, see below) has now landed**, so this feature is complete across both engines.
- [x] Control loop:
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server pushes per-node events over the WS (`debugSubscribe`/`debugNode`) via a new `DebugObserver` on the engine (lock-free `atomic.Value`, gated by a per-graph watch set) + `internal/server/debughub.go` (drop-on-full fan-out). Two modes share one message shape: **Live** observes the running enabled graph; **Simulate** dry-runs the unsaved edits in a throwaway sandbox (`StartSimulate`, side effects suppressed). Live/Sim sub-toggle in the editor
- [x] add full support to server side array values — DONE (Phase 2): `Value = float64 | []Value` model (`internal/controllogic/value.go`), `Graph.StateVars` (number/bool/array with dynamic/capped/fixed sizing) persisted in store JSON, value-polymorphic `expr.go` (array literals, negative-wrap indexing, full array-function table), and `action.array.push|set|remove|pop|clear` nodes. Lua block stays scalar-only (array local → NaN); CSV export remains panel-logic-only
- [x] Implement git style versioning for: synthetic variable, panels, control logic:
- [x] possibility to fork any version
- [x] click to view the version
@@ -78,10 +112,31 @@
- [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
- [x] Implement admin pane: create / manage groups, set users permits, manage auditors etc
- [x] user manager
- [x] group manager
- [x] server statistics: load, conenctions, observed signals, average latency, other statistics
- [ ] Implement new datasources:
- [ ] Finalize alarm service
- [ ] modbus tcp
- [ ] scpi tcp
- [ ] scpi tcp / VXI-11 protocol
- [ ] udp? other?
- [ ] **MAJOR** Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
- [ ] clients should be distributed to balance load\
- same user should be (if possible) connect to only one service to simplify synch issue
- [ ] control sequences should be executed in only in one server instance but with backups ones to take over if the active service stop or die
- [ ] sync config between service
- [ ] manage conflict
- [ ] avoid incorrect
- [ ] ensure that the system can survive with up to only one instance alive
- [ ] advance admin panel:
- [ ] should have info about service topology and statistics of each server
- [ ] admin should deploy new instances via ssh to target machines directly from the admin panel
- [ ] for phisical connect datasource (e.g. modbus) pin the service to a specific machine or deploy specific datasource only service to a machine: user can setup backup instances (e.g. machine in the same sub-network that can be switched on in case primary fail)
- [ ] QA and CI
- [ ] coverage of service 90+%
- [ ] coverage of client 80+%
- [ ] add integrated tests with interface simulations
- [ ] update doc and keep user manual up to date
- [ ] add code example for lua scripts
- [ ] add tutorial
+150 -11
View File
@@ -13,6 +13,7 @@ import (
"path/filepath"
"syscall"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
@@ -20,10 +21,14 @@ import (
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/epics"
"github.com/uopi/uopi/internal/datasource/modbus"
"github.com/uopi/uopi/internal/datasource/pva"
"github.com/uopi/uopi/internal/datasource/scpi"
"github.com/uopi/uopi/internal/datasource/servervar"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/ldapauth"
"github.com/uopi/uopi/internal/pamauth"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/server"
"github.com/uopi/uopi/internal/storage"
@@ -143,6 +148,35 @@ func main() {
}
}
// Modbus TCP data source: polls configured holding/input/coil/discrete
// registers on one or more devices. Disabled unless [datasource.modbus] is
// configured with devices.
if cfg.Datasource.Modbus.Enabled {
mbDS, err := modbus.New(cfg.Datasource.Modbus)
if err != nil {
log.Error("modbus init", "err", err)
} else if err := mbDS.Connect(ctx); err != nil {
log.Error("modbus connect", "err", err)
} else {
brk.Register(mbDS)
context.AfterFunc(ctx, mbDS.Close)
}
}
// SCPI data source: polls instrument channels over raw TCP sockets.
// Disabled unless [datasource.scpi] is configured with instruments.
if cfg.Datasource.SCPI.Enabled {
scpiDS, err := scpi.New(cfg.Datasource.SCPI)
if err != nil {
log.Error("scpi init", "err", err)
} else if err := scpiDS.Connect(ctx); err != nil {
log.Error("scpi connect", "err", err)
} else {
brk.Register(scpiDS)
context.AfterFunc(ctx, scpiDS.Close)
}
}
// Server variables: a small persistent key/value source ("srv") that the
// control-logic engine writes (e.g. sequence state) and panels can read.
srvVars, err := servervar.New(cfg.Server.StorageDir)
@@ -152,18 +186,32 @@ func main() {
}
brk.Register(srvVars)
// Build the global access policy from config: every user is trusted with
// full write access unless downgraded by the blacklist; groups are named
// sets of users referenced by per-panel sharing.
blacklist := make(map[string]string, len(cfg.Server.Blacklist))
for _, e := range cfg.Server.Blacklist {
blacklist[e.User] = e.Level
}
groups := make(map[string][]string, len(cfg.Groups))
// Build the role-based access policy from config: each [[groups]] entry lists
// members by role (viewer/operator/logiceditor/auditor/admin) and an optional
// parent for nesting. Every user is an implicit viewer of the built-in public
// group; a config with no roles at all is fully open (everyone admin).
specs := make([]access.GroupSpec, 0, len(cfg.Groups))
for _, g := range cfg.Groups {
groups[g.Name] = g.Members
members := make(map[string]access.Role)
assign := func(users []string, role access.Role) {
for _, u := range users {
members[u] = role
}
}
assign(g.Viewers, access.RoleViewer)
assign(g.Operators, access.RoleOperator)
assign(g.LogicEditors, access.RoleLogic)
assign(g.Auditors, access.RoleAuditor)
assign(g.Admins, access.RoleAdmin)
specs = append(specs, access.GroupSpec{Name: g.Name, Parent: g.Parent, Members: members})
}
policy := access.New(cfg.Server.DefaultUser, specs)
// Once enabled, runtime admin-pane changes persist to {storage_dir}/access.json,
// which (when present on a later startup) supersedes the TOML access config.
if err := policy.EnablePersistence(cfg.Server.StorageDir); err != nil {
log.Error("failed to load access store", "err", err)
os.Exit(1)
}
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors, cfg.Audit.Readers)
// Audit log: when enabled, every system-affecting action (user/automated
// signal writes, interface and control-logic mutations) is recorded to SQLite.
@@ -199,9 +247,100 @@ func main() {
// to server variables. Installed before Reload so running graphs can emit.
dialogs := server.NewDialogHub(brk, policy, recorder, log)
ctrlEngine.SetNotifier(dialogs)
// Debug hub: control-logic editors observe live graphs or dry-run unsaved
// edits; the engine pushes per-node execution events through it.
debugHub := server.NewDebugHub(ctrlEngine, log)
ctrlEngine.SetDebugObserver(debugHub)
ctrlEngine.Reload()
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
// Native SPNEGO/Kerberos authentication (optional): load the service keytab so
// the server can validate browser Negotiate tickets and resolve users directly.
var krbKeytab *keytab.Keytab
if cfg.Server.Kerberos.Enabled {
if cfg.Server.Kerberos.Keytab == "" {
log.Error("kerberos enabled but no keytab configured (server.kerberos.keytab)")
os.Exit(1)
}
kt, err := keytab.Load(cfg.Server.Kerberos.Keytab)
if err != nil {
log.Error("failed to load kerberos keytab", "path", cfg.Server.Kerberos.Keytab, "err", err)
os.Exit(1)
}
krbKeytab = kt
log.Info("native SPNEGO/Kerberos authentication enabled",
"keytab", cfg.Server.Kerberos.Keytab, "service_principal", cfg.Server.Kerberos.ServicePrincipal)
}
// Built-in HTTP Basic authentication (optional): challenge the browser with a
// login dialog and validate credentials against either the host PAM stack
// (basic_auth) or an LDAP directory (ldap), so users log in with their normal
// accounts. The three built-in auth methods (kerberos, basic_auth, ldap) are
// mutually exclusive.
var basicAuthFn func(user, pass string) error
var basicAuthRealm string
enabledAuth := 0
for _, on := range []bool{cfg.Server.Kerberos.Enabled, cfg.Server.BasicAuth.Enabled, cfg.Server.LDAP.Enabled} {
if on {
enabledAuth++
}
}
if enabledAuth > 1 {
log.Error("server.kerberos, server.basic_auth and server.ldap are mutually exclusive; enable only one")
os.Exit(1)
}
if cfg.Server.BasicAuth.Enabled {
if !pamauth.Available {
log.Error("basic_auth enabled but this binary lacks PAM support; rebuild with: make backend-pam (or use server.ldap, which needs no cgo)")
os.Exit(1)
}
service := cfg.Server.BasicAuth.PAMService
if service == "" {
service = "uopi"
}
basicAuthFn = func(user, pass string) error {
return pamauth.Authenticate(service, user, pass)
}
basicAuthRealm = "uopi"
log.Info("built-in HTTP Basic authentication enabled (PAM)", "pam_service", service)
}
if cfg.Server.LDAP.Enabled {
ldapAuth, err := ldapauth.New(ldapauth.Config{
URIs: cfg.Server.LDAP.URIs,
SearchBase: cfg.Server.LDAP.SearchBase,
UserAttr: cfg.Server.LDAP.UserAttr,
UserObjectClass: cfg.Server.LDAP.UserObjectClass,
BindDN: cfg.Server.LDAP.BindDN,
BindPassword: cfg.Server.LDAP.BindPassword,
StartTLS: cfg.Server.LDAP.StartTLS,
CACertFile: cfg.Server.LDAP.CACert,
InsecureSkipVerify: cfg.Server.LDAP.InsecureSkipVerify,
})
if err != nil {
log.Error("ldap auth misconfigured", "err", err)
os.Exit(1)
}
basicAuthFn = ldapAuth.Authenticate
basicAuthRealm = "uopi"
log.Info("built-in HTTP Basic authentication enabled (LDAP)", "uri", cfg.Server.LDAP.URIs, "search_base", cfg.Server.LDAP.SearchBase)
}
if basicAuthFn != nil && !cfg.Server.TLS.Enabled {
log.Warn("HTTP Basic authentication enabled without TLS; credentials are sent in clear text — enable server.tls unless on a fully isolated network")
}
// Built-in TLS (optional): terminate HTTPS directly, recommended whenever
// Basic auth is enabled.
var tlsCert, tlsKey string
if cfg.Server.TLS.Enabled {
if cfg.Server.TLS.Cert == "" || cfg.Server.TLS.Key == "" {
log.Error("server.tls enabled but cert/key not configured (server.tls.cert, server.tls.key)")
os.Exit(1)
}
tlsCert, tlsKey = cfg.Server.TLS.Cert, cfg.Server.TLS.Key
log.Info("built-in TLS enabled", "cert", tlsCert)
}
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.Server.TLS.RedirectFrom, cfg.UI.DefaultZoom, log)
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
+6
View File
@@ -236,6 +236,9 @@ A signal defined by composing one or more input signals through a chain of proce
processing node, set parameters, Create.
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
It supports undo/redo (Ctrl+Z / Ctrl+Shift+Z) and copy/paste (Ctrl+C / Ctrl+V) of the
selection; paste remaps ids and preserves internal wires (the permanent output node is
never duplicated).
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
that created it), *user*, or *global* (shared with everyone).
@@ -303,6 +306,9 @@ managed through the REST API.
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.
- The editor has its own undo/redo and copy/paste (Ctrl+Z / Ctrl+Shift+Z, Ctrl+C / Ctrl+V;
also ↩/↪ toolbar buttons). Copy/paste is multi-select aware and preserves wires internal
to the selection.
- Editing is gated by the logic-edit restriction (§2).
---
+238 -22
View File
@@ -164,6 +164,22 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
// Request historical data
{ "type": "history", "signal": "EPICS:PV1", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z", "maxPoints": 5000 }
// Start a control-logic live-debug session (one per client; replaces any prior).
// mode "live" — observe the running, enabled graph identified by graphId.
// mode "simulate" — dry-run the unsaved `graph` in a server sandbox (no real
// writes/config/dialogs); re-send on each edit to refresh it.
{ "type": "debugSubscribe", "mode": "live", "graphId": "g1" }
{ "type": "debugSubscribe", "mode": "simulate", "graph": { /* unsaved control-logic graph */ } }
// Stop the current debug session (tears down any simulate sandbox).
{ "type": "debugUnsubscribe" }
// Force a trigger node of the current debug session (live or simulate) to run
// now, as if it had fired. nodeId must be a trigger node of the watched graph;
// best-effort (dropped if the session is gone). Drives the editor's
// double-click-to-fire gesture on trigger nodes in debug mode.
{ "type": "fireTrigger", "nodeId": "t" }
```
**Server → Client messages:**
@@ -178,6 +194,11 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
// Historical data response
{ "type": "history", "signal": "EPICS:PV1", "points": [ { "ts": "...", "value": 1.2 }, ... ] }
// Control-logic node execution during a live-debug session. Emitted ~per node
// run for the watched graph (live) or sandbox (simulate); value is meaningful
// only when hasValue is true (e.g. an action.write's value, a flow.if branch 0/1).
{ "type": "debugNode", "graphId": "g1", "nodeId": "w", "value": 42, "hasValue": true, "ts": 1750000000000 }
// Error
{ "type": "error", "code": "NOT_FOUND", "message": "Signal not found" }
```
@@ -205,6 +226,7 @@ Base path: `/api/v1`
| GET, PUT | `/groups` | Read or set group definitions |
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
| POST | `/synthetic/trace` | Stateless single-shot trace of an unsaved graph for the live-debug view — every node's value; stateful ops flagged `approx` |
| 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) |
@@ -324,10 +346,40 @@ mount and `clear()` on unmount. The engine subscribes to every referenced signal
cache, then on each trigger activation walks the wired graph (gates, if/loop, actions),
evaluating expression fields via a small safe recursive-descent evaluator (no `eval`).
Triggers include button/threshold/change/timer/loop and On-open/On-close lifecycle; actions
include signal writes, delay, log, in-memory data arrays (accumulate/export-CSV/clear) and
include signal writes, delay, log, array mutations (see below) and
user dialogs (info/error/set-point). The engine runs entirely in the browser — no backend
component.
**Array-valued local variables.** Panel-local variables (`<statevar>`) may be declared with
`type="array"`. An array statevar carries `elem` (`number`|`bool`|`array`, the element kind —
`array` gives a 2-D array), `sizing`, and `capacity`. The three sizing policies
(`web/src/lib/arraypolicy.ts`) are: **dynamic** (unbounded up to `ARRAY_MAX = 1_000_000`,
oldest dropped past the cap), **capped** (length kept ≤ `capacity`, oldest dropped FIFO), and
**fixed** (exactly `capacity` elements — truncated or zero-padded). Sizing is enforced only at
store time (`writeLocalState``applySizing`); expressions themselves are pure and produce
unbounded values. The value model is the tagged union `ArrVal = number | ArrVal[]` with
booleans represented as `1`/`0` at the leaves.
The expression evaluator (`web/src/lib/expr.ts`) is value-polymorphic: `evalValue` returns an
`ArrVal`, while `evalExpr` returns a `number` (`NaN` if the result is an array). It supports
array literals `[a, b, c]`, indexing `a[i]` (negative indices count from the end), and a table
of array functions: `len`, `sum`, `mean`, `slice`, `concat`, `reverse`, `sort`, `scale`, `add`,
`sub`, `push`, `set`, `insert`, `remove`, `pop`, `shift`, `indexOf`, `contains`, `fill` (plus
`min`/`max`, which accept either scalars or an array). These are pure — they return new values
and never mutate a stored variable.
Array mutation nodes write back to a declared array statevar: `action.array.push`, `…set`,
`…remove`, `…pop`, and `…clear`. The legacy `action.accumulate` and `action.clear` nodes are
retained as aliases over `action.array.push` / `action.array.clear`, and legacy graphs are
auto-migrated to declare their backing arrays on load (`ensureArrayDecls`). `action.export`
now produces **index-aligned** CSV: each configured array becomes a column (custom per-column
labels supported), rows aligned by element index. Array statevars and all array nodes
round-trip through the panel XML (`<statevar type="array" elem=… sizing=… capacity=…>`).
> **Note:** The above is Phase 1 (panel logic, client-side TypeScript). The equivalent array
> support in the server-side control-logic engine (`internal/controllogic`) is Phase 2 and has
> now landed — see the **Array-valued local variables** subsection of §3.9 below.
### 3.9 Control Logic Engine
Control logic is server-side (`internal/controllogic`): always-on flow graphs that run under
@@ -337,6 +389,26 @@ 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.
**Array-valued local variables (Phase 2).** Engine locals now carry a `Value` — the tagged
union `Value = float64 | []Value` (`internal/controllogic/value.go`), the Go port of the
panel-logic `ArrVal` model, with booleans represented as `1`/`0` at the leaves. A graph may
declare `statevars`: each has a `Name`, a `Type` (`number` | `bool` | `array`), an `Initial`
expression, and — for arrays — a `Sizing` policy and `Capacity`. The three sizing policies
mirror the frontend: **dynamic** (unbounded up to `ARRAY_MAX = 1_000_000`, oldest dropped
FIFO past the cap), **capped** (length kept ≤ `Capacity`, oldest dropped FIFO), and **fixed**
(exactly `Capacity` elements — truncated or zero-padded). Sizing is enforced on write. State
vars are persisted in the graph's store JSON and seeded into locals at compile time.
The expression evaluator (`internal/controllogic/expr.go`) is value-polymorphic: it supports
array literals `[a, b, c]`, indexing `arr[i]` (negative indices wrap from the end), and a
table of array functions: `len`, `sum`, `mean`, `slice`, `concat`, `reverse`, `sort`, `scale`,
`add`, `sub`, `push`, `set`, `insert`, `remove`, `pop`, `shift`, `indexOf`, `contains`, `fill`
(plus `min`/`max`, which accept either scalars or an array). These are pure — they produce new
values and never mutate a stored local. Five action nodes write back to a declared array
statevar: `action.array.push`, `…set`, `…remove`, `…pop`, and `…clear`. The embedded Lua block
remains **scalar-only**: reading an array local from Lua yields `NaN`. CSV export
(`action.export`) is panel-logic-only and is **not** available in control logic.
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
@@ -360,14 +432,116 @@ instance's `setId` in its `Meta`.
### 3.10 Access Control
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
is read per request from `server.trusted_user_header` (with a `default_user` fallback) and
stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's
global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in
`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces
the optional `server.logic_editors` allowlist over control-logic endpoints and over any
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.
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
identity is read per request from `server.trusted_user_header` (with a `default_user`
fallback) and stored on the request context. Alternatively, **native SPNEGO/Kerberos**
authentication (`[server.kerberos]`) lets uopi identify users directly from their Kerberos
ticket without a reverse proxy: `internal/server/kerberos.go` wraps the REST and WebSocket
handlers, challenges API requests with `401 WWW-Authenticate: Negotiate`, validates the
ticket against the configured service keytab (`github.com/jcmturner/gokrb5`), and writes the
short principal name (realm stripped) into the same `userHeader` the access pipeline reads
— so downstream identity resolution is identical. Any client-supplied header value is
discarded before validation to prevent spoofing. WebSocket upgrades (where browsers cannot
attach an `Authorization` header) validate proactively-sent credentials best-effort and
otherwise fall back to `default_user`. Browsers must be configured to perform SPNEGO for the
server origin (Firefox: `network.negotiate-auth.trusted-uris`), which fixes the case where
Firefox would otherwise resolve to `default_user`. A third standalone option is **built-in
HTTP Basic authentication** (`[server.basic_auth]`, mutually exclusive with Kerberos):
`internal/server/basicauth.go` challenges with `401 WWW-Authenticate: Basic` and validates
credentials against the host PAM stack (`internal/pamauth`, `/etc/pam.d/<pam_service>`), so
on an SSSD/LDAP-joined host users authenticate with their normal login password and no
directory schema is needed in uopi. Successful logins are memoised in a short-TTL salted-hash
cache (`credCache`) to avoid a PAM round-trip per request, and the validated username is
written into the same `userHeader`. PAM requires a cgo build (`make backend-pam`, build tag
`pam`); the default static binary uses a stub that refuses Basic auth. As a **pure-Go**
alternative that keeps the fully-static (`CGO_ENABLED=0`) binary, `[server.ldap]`
(`internal/ldapauth`) validates the same Basic credentials against an LDAP directory with a
"search then bind" — anonymously (or via a service `bind_dn`) locate the user entry under
`search_base`, then bind as its DN with the supplied password — mirroring an SSSD/LDAP
client (defaults: `user_attr=uid`, `user_object_class=posixAccount`). Empty passwords are
rejected before any bind to avoid LDAP "unauthenticated bind", and the login name is
filter-escaped against injection. The challenge front-end is shared: both PAM and LDAP feed
the same `basicAuth` middleware, and the page load (`/`) is challenged too so the browser's
native login dialog actually appears (a background `fetch('/me')` 401 does not prompt).
Because Basic credentials are sent on every request, uopi can terminate **TLS** itself
(`[server.tls]``ListenAndServeTLS`) without a reverse proxy; an optional
`redirect_from` plain-HTTP listener 301-redirects `http://` visitors to the HTTPS service
so they are upgraded instead of hitting the TLS port with cleartext ("client sent an HTTP
request to an HTTPS server"). The four identity sources
(proxy header, Kerberos, PAM-Basic, LDAP-Basic) all resolve to the same `userHeader` and are
mutually exclusive where they overlap. Access is **role-based** through group
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
capability is the highest role across all their memberships. Roles map to capabilities as:
operator+ → write (`Level` is derived, `LevelWrite` for operator+, else `LevelRead`);
logiceditor+ → `CanEditLogic`; auditor+ → `CanViewAudit`; admin → `CanAdmin`.
`accessMiddleware` gates mutating HTTP methods by the derived global level. Per-panel
ownership and ACL evaluation (with folder inheritance) live in `internal/panelacl`, backed
by the `acl.json` sidecar.
A built-in **public** group is always present: every user (and anonymous) is an implicit
viewer member, so an identified caller is at least read-only. Groups may **nest** via a
`parent` pointer (a forest rooted at top-level groups); a member of a parent group inherits
its role on every descendant group too, unless overridden lower. Cycles are rejected
(offending parent dropped to root), and the public group is protected from rename, reparent,
and delete. As a bootstrap convenience, a `Policy` with **no roles assigned anywhere** is
treated as unconfigured → fully open (everyone is admin), matching trusted-LAN/dev use;
assigning any role switches to strict mode where unlisted users are read-only viewers.
The capability checks (`CanEditLogic`, `CanViewAudit`, `CanAdmin`) are surfaced to the
frontend through `/api/v1/me` (`canEditLogic`, `canViewAudit`, `canAdmin`) so the UI can
hide affordances. The `Policy` is seeded from the TOML config at startup but is
**runtime-mutable** through the admin pane. `Policy.EnablePersistence(storageDir)` points
it at an `access.json` sidecar; once any admin mutation is made, that file is written (tmp +
atomic rename) and, on a later startup, supersedes the TOML access config (which then only
bootstraps an empty install). All policy state is guarded by an `RWMutex` (reads share,
mutations are exclusive and persist), keeping the shared `*Policy` pointer wiring intact.
The admin REST routes live under `/api/v1/admin/*` (all `requireAdmin`-gated):
`GET /admin/access` returns an `AccessSnapshot` (users with effective role + per-group
roles, groups with members and parents, the role ladder, and the configured flag);
`PUT /admin/users/{user}` replaces a user's full set of per-group roles (body
`{roles: {group: role}}`, creating missing groups); `POST|PUT|DELETE /admin/groups[/{name}]`
create (name + optional parent), rename + set parent and member roles, and delete groups;
and `GET /admin/stats` reports live server statistics (the `internal/metrics` counters via
`metrics.Snapshot`, the broker's observed-signal count and data-source list, Go runtime
stats, and the Linux `/proc/loadavg` load average). The frontend `AdminPane.tsx` (a modal
opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-group role
assignment with an effective-role badge), Groups (nesting + per-member roles), and
Server-stats tabs.
#### Visibility scope (selector-tree filtering)
Every user-owned, list-able object — panels, synthetic signals, config sets/instances, and
control-logic graphs — carries a uniform **visibility scope** so each selector tree can be
filtered by **Mine / Group / Global**. The model is `owner` (stamped server-side from the
trusted identity on create, immutable across updates) plus a scope token
`∈ {private, group, global}` and, for group scope, a list of group names. An empty or
unknown token resolves to **global**, so legacy objects with no scope stay visible to
everyone. This is a *visibility filter*, not a hard security boundary: an owner always sees
their own objects regardless of scope, and the per-panel ACL (§3.10) remains the real
access-control mechanism for panels.
The shared backend helper is `access.CanSee(user, owner, scope, itemGroups, userGroups)`
(`internal/access/scope.go`), with a `(*Policy).CanSee` method that resolves the caller's
groups via `GroupsOf`. Each list endpoint filters its results through it:
`internal/confmgr` stores `owner/scope/groups` on sets and instances (filtered by
`filterConfigMetas`); synthetic `SignalDef` gained a `group` visibility mode routed through
`synVisible`; control-logic `Graph` carries `owner/scope/scopeGroups` (named to avoid the
pre-existing cosmetic `Groups []NodeGroup`) filtered in `listControlLogic`. Panels reuse the
existing ACL rather than a new field: `panelScope` (`internal/api/api.go`) derives the
bucket from the ACL record (public → global, a group grant → group, otherwise → private;
unmanaged → global) and surfaces it on `InterfaceListItem.scope`/`groups`.
The shared frontend lib is `web/src/lib/scope.tsx`: `bucketOf` assigns an item to exactly
one bucket (owned → mine, else group-scoped → group, else global), `filterByScope` narrows a
list to the active bucket (with an optional groups-accessor for control-logic's
`scopeGroups`), `ScopeFilter` is the segmented `[Mine | Group ▾ | Global]` selector shown
above each tree (the Group segment carries a combo to pick a group when the user is in
several), and `ScopePicker` is the create/save visibility editor. `ConfigManager.tsx`,
`SyntheticGraphEditor.tsx`, and `ControlLogicEditor.tsx` use the picker to set scope on save;
panel visibility is instead edited through the existing Share dialog. `InterfaceList.tsx`
applies the filter to its folder tree, hiding folders that have no in-scope descendant.
### 3.11 Configuration Manager
@@ -478,6 +652,7 @@ Both edit and view modes support mouse-drag panel resizing:
- `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%.
- Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font.
- **ZoomControl** (A / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load.
- The root font-size also folds in `window.devicePixelRatio` (`applyZoom``16 × zoom × dpr`) so high-DPI displays auto-scale the UI by default; Firefox in particular does not enlarge the root px on its own, so the DPR must be applied explicitly. `watchDpr()` re-applies the zoom when the ratio changes (e.g. the window moves to a differently-scaled monitor).
- Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly.
### 4.8 Lua Editor (`LuaEditor.tsx`)
@@ -565,15 +740,55 @@ storage_dir = "./interfaces"
# Access control (all optional)
trusted_user_header = "" # header carrying the proxy-authenticated user
default_user = "" # identity when the header is absent (LAN/dev)
logic_editors = [] # users/groups allowed to edit panel & control logic
# [[server.blacklist]] # downgrade users: level = "readonly" | "noaccess"
# user = "guest"
# level = "readonly"
# Native SPNEGO/Kerberos auth — alternative to a proxy; identifies users from
# their Kerberos ticket. Recommended when some browsers (e.g. Firefox) would
# otherwise fall through to default_user.
# [server.kerberos]
# enabled = true
# keytab = "/etc/uopi/http.keytab" # HTTP/host@REALM service key
# service_principal = "HTTP/host.example.com" # optional; empty = keytab default
# [[groups]] # named user sets for per-panel sharing
# name = "operators"
# members = ["alice", "bob"]
# Built-in HTTP Basic auth (PAM) — standalone, mutually exclusive with Kerberos.
# Requires a PAM build: `make backend-pam`. Enable TLS below for production.
# [server.basic_auth]
# enabled = true
# pam_service = "uopi" # /etc/pam.d/<name>; empty = "uopi"
# Built-in HTTP Basic auth (LDAP) — pure-Go, works in the static binary; search
# then bind against the directory. Mutually exclusive with kerberos/basic_auth.
# [server.ldap]
# enabled = true
# uri = ["ldaps://ldap.example.com"]
# search_base = "dc=example,dc=com"
# user_attr = "uid" # "sAMAccountName" for AD; empty = uid
# bind_dn = "" # empty = anonymous search
# Built-in TLS/HTTPS — terminate HTTPS without a reverse proxy. Recommended
# whenever basic_auth is enabled. Both cert and key required when enabled.
# [server.tls]
# enabled = true
# cert = "/etc/uopi/tls/cert.pem"
# key = "/etc/uopi/tls/key.pem"
# Role-based access through group memberships. Roles (low→high):
# viewer < operator < logiceditor < auditor < admin
# Effective capability = highest role across all memberships. The built-in
# "public" group makes every user an implicit viewer. Groups may nest via
# "parent" (members inherit their role on descendants). No roles anywhere = open
# (everyone admin); once set, unlisted users are read-only viewers.
# [[groups]]
# name = "public"
# admins = ["alice"] # alice is a global admin
# [[groups]]
# name = "operations"
# operators = ["bob"]
# auditors = ["carol"]
# [[groups]]
# name = "engineers"
# parent = "operations" # bob inherits operator here
# logiceditors = ["dave"]
# viewers = ["erin"]
[datasource.epics]
enabled = true
@@ -586,7 +801,7 @@ enabled = true
```
All settings can also be overridden with `UOPI_*` environment variables (e.g.
`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`).
`UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`).
---
@@ -611,11 +826,12 @@ All settings can also be overridden with `UOPI_*` environment variables (e.g.
- Interface XML parsing: use strict schema validation to prevent XXE.
- **Identity & access control:** the end-user identity is taken from a header set by a
trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied
values — the proxy MUST strip any inbound copy of that header or it can be spoofed. A
global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional
`logic_editors` allowlist provide finer control. An unidentified caller (no header, no
`default_user`) is treated as a trusted-LAN user with full write access, preserving the
unproxied/SSH-tunnel deployment model.
values — the proxy MUST strip any inbound copy of that header or it can be spoofed.
Authorisation is role-based through group memberships (viewer/operator/logiceditor/
auditor/admin), with the highest role across memberships deciding global capability;
per-panel ACLs provide finer per-panel control. When **no** roles are assigned anywhere
the deployment is fully open (everyone admin), preserving the unproxied/SSH-tunnel/dev
model; once any role is set, unlisted and anonymous callers are read-only viewers.
---
+87
View File
@@ -0,0 +1,87 @@
# Test Coverage Report — uopi
**Date:** 2026-06-24
**Branch:** develop
**Task:** #167 — Raise backend coverage toward 90%
**Status:** All suites green — `go test ./... -race`, `go vet ./...`, and `gofmt -l` clean.
## Overall
- **Main module total coverage: 67.7%** of statements.
- **38 test files, 218 test/bench/fuzz functions** in the main module.
- **27 new test files** added across the coverage initiative.
- 3-module `go.work` workspace — each module is tested independently (`go test ./...`
from the root only exercises the main module; `pkg/ca` and `pkg/pva` must be tested
by `cd`-ing into them).
## Main module (`github.com/uopi/uopi`) — by package
| Coverage | Package | Notes |
|---|---|---|
| 100.0% | `internal/config` | |
| 100.0% | `internal/pamauth` | stub (non-PAM build) |
| 97.1% | `internal/metrics` | |
| 96.3% | `internal/broker` | signal fan-out core |
| 89.2% | `internal/panelacl` | |
| 89.0% | `internal/audit` | |
| 85.5% | `internal/access` | |
| 82.6% | `internal/storage` | |
| 82.4% | `internal/datasource/stub` | |
| 81.7% | `internal/confmgr` | |
| 81.0% | `internal/dsp` | |
| 79.1% | `internal/datasource/servervar` | |
| 72.9% | `internal/datasource/synthetic` | |
| 67.2% | `internal/api` | large handler surface |
| 66.6% | `internal/controllogic` | engine/Lua/cron uncovered |
| 55.6% | `internal/datasource` | iface + ctx helpers |
| 34.1% | `internal/server` | HTTP/WebSocket |
| 33.9% | `internal/ldapauth` | network-bound |
| 33.6% | `internal/datasource/epics` | CGo/libca-bound |
| 0.0% | `internal/datasource/pva` | network-bound |
| 0.0% | `cmd/uopi`, `cmd/catools`, `cmd/pvtools`, `tools/buildfrontend` | mains — no tests |
## Workspace modules
| Module | Coverage |
|---|---|
| `pkg/ca` (goca) | **83.9%** root · 90.0% `proto` · `testca` 0% (test harness) |
| `pkg/pva` (gopva) | 85.5% `pvdata` · 12.1% root (network client) |
## Remaining gaps toward 90%
The lowest packages are all **I/O- or platform-bound**, needing integration harnesses
rather than unit tests:
- `server` (34%) — HTTP/WebSocket handlers.
- `ldapauth` (34%) — needs a mock LDAP server.
- `datasource/epics` (34%) — CGo `libca` linkage.
- `datasource/pva` (0%) — needs a PVA test server (analogous to `testca` for CA).
- `cmd/*` mains — typically excluded from coverage targets.
Pure-logic packages are now in the 80100% range. The biggest realistic remaining
wins are `api` (67%) and `controllogic` (67%), where the uncovered code is the
control-logic **engine** (Lua runtime, cron scheduling, dialog emission) and the
network-dependent API handlers (channelFinder, archiverSearch).
## Coverage gains (this initiative)
| Package | Before | After |
|---|---|---|
| `internal/api` | 53.8% | 67.2% |
| `internal/audit` | 75.3% | 89.0% |
| `internal/broker` | 78.9% | 96.3% |
| `internal/panelacl` | 65.9% | 89.2% |
| `internal/confmgr` | 73.4% | 81.7% |
| `internal/dsp` | 66.3% | 81.0% |
| `internal/datasource` | 0% | 55.6% |
| `internal/pamauth` | 0% | 100% |
| `pkg/ca` | 82.2% | 83.9% |
## Notes
- Two inherently racy assertions were deliberately dropped (broker cancelled-context
`ReadNow`; audit closed-channel synchronous fallback): both depended on
nondeterministic `select` ordering and flaked under `-race`. The surrounding code
paths remain covered by other tests.
- All new test files are `gofmt`-clean, matching the CI gate
(`gofmt -l $(git ls-files '*.go')`).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,779 @@
# Local Array Values — Phase 1 (Panel Logic, TypeScript) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add first-class array-valued local variables to the panel-logic (client TS) flow engine — declaration with dynamic/capped/fixed sizing, an array-aware expression language, mutation nodes (unifying the legacy accumulate/export/clear arrays), persistence, and binding from the plot / table / multi-LED widgets.
**Architecture:** The expression evaluator (`expr.ts`) becomes value-polymorphic — `EvalValue = number | EvalValue[]` (tagged union, "Approach 1"). Array locals are declared as a new `type:'array'` `StateVar` and held per-panel in `localstate.ts`, which enforces the sizing policy on every write. Mutation is performed by new `action.array.*` nodes; reads/transforms are pure functions in the expression language. Widgets read array locals through the existing `ds:'local'` plumbing.
**Tech Stack:** Preact 10 + TypeScript, bundled by esbuild via the Go tool `tools/buildfrontend/main.go` (no npm/node). Stores are the hand-rolled `web/src/lib/store.ts` writable/readable primitives.
## Global Constraints
- No npm / no Node.js. Frontend builds **only** via `make frontend` (esbuild, pure Go). There is **no JS test runner and no typecheck gate** — esbuild strips types without checking them. Phase-1 verification = `make frontend` builds clean + the manual smoke checklist in Task 9.
- The authoritative automated tests for the shared array semantics live in **Phase 2 (Go `expr.go`)**, not here. Keep `expr.ts` semantics documented precisely so the Go port can mirror them exactly.
- Booleans are numbers (`1`/`0`) at array leaves; `elem:'bool'` is display metadata only.
- Expressions are **pure** (no side effects). Mutator-named functions (`push/set/insert/remove/pop/shift`) return **new** arrays; the sizing policy is enforced only at store time in `localstate.ts`.
- Dynamic arrays are guarded by a global safety cap `ARRAY_MAX = 1_000_000` elements.
- Preserve backward compatibility: existing `accumulate`/`clear`/`export` nodes keep working via aliasing + auto-declared locals.
- Follow existing file conventions; commit frequently with `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`.
---
## File Structure
- `web/src/lib/types.ts` — extend `StateVar` with array fields. (Modify)
- `web/src/lib/expr.ts` — polymorphic value model, array literals, indexing, array functions. (Modify — the core change)
- `web/src/lib/localstate.ts` — array init from `initial`, sizing-policy enforcement on write, array metadata. (Modify)
- `web/src/lib/arraypolicy.ts`**new**: pure helpers `applySizing(value, sv)` and `parseInitialArray(sv)` shared by `localstate.ts` and `logic.ts`. (Create)
- `web/src/lib/logic.ts` — new `action.array.*` node handlers; accumulate/clear aliasing; export-by-index with custom headers; auto-declare migration in `load()`. (Modify)
- `web/src/lib/types.ts` `LogicNodeKind` — add the new node kinds. (Modify, same file as above)
- `web/src/lib/xml.ts` — round-trip the new `<statevar>` array attributes. (Modify)
- `web/src/LogicEditor.tsx``LocalVars` array declaration form + array node palette entries + inspectors. (Modify)
- `web/src/lib/flowDebug.ts` — compact array stringify for node value badges. (Modify)
- `web/src/widgets/PlotWidget.tsx`, `web/src/widgets/TableWidget.tsx`, `web/src/widgets/MultiLed.tsx` (exact filenames verified in Task 7) — array source modes. (Modify)
---
## Task 1: Extend `StateVar` with array fields
**Files:**
- Modify: `web/src/lib/types.ts` (the `StateVar` interface, ~lines 69-76)
**Interfaces:**
- Produces: `StateVar` with new optional fields `elem?: 'number'|'bool'|'array'`, `sizing?: 'dynamic'|'capped'|'fixed'`, `capacity?: number`, and `type` union extended with `'array'`.
- [ ] **Step 1: Extend the interface**
In `web/src/lib/types.ts`, change the `StateVar` interface to:
```ts
export interface StateVar {
name: string;
type?: 'number' | 'bool' | 'string' | 'array';
initial: string;
unit?: string;
low?: number;
high?: number;
// array-only (present when type === 'array'):
elem?: 'number' | 'bool' | 'array';
sizing?: 'dynamic' | 'capped' | 'fixed';
capacity?: number;
}
```
- [ ] **Step 2: Build**
Run: `make frontend`
Expected: `Frontend built successfully → …/web/dist`
- [ ] **Step 3: Commit**
```bash
git add web/src/lib/types.ts
git commit -m "feat(logic): add array fields to StateVar type"
```
---
## Task 2: Array sizing-policy helpers (`arraypolicy.ts`)
**Files:**
- Create: `web/src/lib/arraypolicy.ts`
**Interfaces:**
- Consumes: `StateVar`, `EvalValue` (Task 3 finalizes `EvalValue`; here use `type EvalValue = number | EvalValue[]` locally re-exported from `expr.ts` once Task 3 lands — for ordering, define the alias in this file and have `expr.ts` import it).
- Produces:
- `export type ArrVal = number | ArrVal[];`
- `export const ARRAY_MAX = 1_000_000;`
- `export function parseInitialArray(sv: StateVar): ArrVal[]` — initial contents for an array local.
- `export function applySizing(arr: ArrVal[], sv: StateVar): ArrVal[]` — enforce dynamic/capped/fixed on a candidate array value.
- [ ] **Step 1: Create the module**
```ts
// web/src/lib/arraypolicy.ts
// Pure helpers for array-valued local state: parse the declared initial value
// and enforce the declared sizing policy (dynamic / capped / fixed). Shared by
// localstate.ts (write path) and logic.ts (node handlers + migration).
import type { StateVar } from './types';
export type ArrVal = number | ArrVal[];
export const ARRAY_MAX = 1_000_000;
// zeroFill builds a length-n array of zeros (flat; fixed nested init must come
// from an explicit `initial` literal).
function zeroFill(n: number): ArrVal[] {
return new Array(Math.max(0, n)).fill(0);
}
// parseInitialArray returns the starting contents of an array local.
export function parseInitialArray(sv: StateVar): ArrVal[] {
const cap = sv.capacity ?? 0;
const raw = (sv.initial ?? '').trim();
let parsed: ArrVal[] | null = null;
if (raw) {
try {
const j = JSON.parse(raw);
if (Array.isArray(j)) parsed = j as ArrVal[];
} catch { parsed = null; }
}
if (sv.sizing === 'fixed') {
if (!parsed) return zeroFill(cap);
// truncate / zero-pad to capacity
const out = parsed.slice(0, cap);
while (out.length < cap) out.push(0);
return out;
}
return parsed ?? [];
}
// applySizing returns arr clamped to the declared policy.
// dynamic → unchanged (but globally capped at ARRAY_MAX, dropping oldest)
// capped → keep at most capacity elements, dropping oldest (ring/FIFO)
// fixed → exactly capacity elements (truncate / zero-pad); never grow/shrink
export function applySizing(arr: ArrVal[], sv: StateVar): ArrVal[] {
const cap = sv.capacity ?? 0;
switch (sv.sizing) {
case 'fixed': {
const out = arr.slice(0, cap);
while (out.length < cap) out.push(0);
return out;
}
case 'capped':
return arr.length > cap ? arr.slice(arr.length - cap) : arr;
default:
return arr.length > ARRAY_MAX ? arr.slice(arr.length - ARRAY_MAX) : arr;
}
}
```
- [ ] **Step 2: Build**
Run: `make frontend`
Expected: builds clean (module compiles; not yet imported anywhere).
- [ ] **Step 3: Commit**
```bash
git add web/src/lib/arraypolicy.ts
git commit -m "feat(logic): add array sizing-policy helpers"
```
---
## Task 3: Make `expr.ts` value-polymorphic
This is the core change. `Resolver` and the evaluator move from `number` to `ArrVal = number | ArrVal[]`. Add array-literal and indexing syntax plus the array function set. Keep all existing scalar behavior identical (numbers, booleans-as-1/0, operators, ternary, the existing math funcs).
**Files:**
- Modify: `web/src/lib/expr.ts` (whole file — see current contents)
- Modify import in: any caller of `Resolver`/`evalExpr` that assumed a `number` return — audit in Step 5.
**Interfaces:**
- Consumes: `ArrVal` from `./arraypolicy`.
- Produces:
- `export type Resolver = (ds: string, name: string) => ArrVal;`
- `export function evalValue(src: string, resolve: Resolver): ArrVal` — full value (number or array).
- `export function evalExpr(src: string, resolve: Resolver): number`**kept** for scalar callers; returns `NaN` if the value is an array or unparseable.
- `export function evalBool(src, resolve): boolean` — unchanged signature.
- `export function collectRefs(src): RefLite[]` — now also walks array-literal / index AST nodes.
- `export function checkExpr(src): string|null` — unchanged signature; parser now accepts the new syntax.
- [ ] **Step 1: Add AST nodes for array literal and indexing**
In the `Node` union add:
```ts
| { t: 'arr'; items: Node[] }
| { t: 'index'; a: Node; i: Node }
```
- [ ] **Step 2: Tokenizer — add `[` and `]`**
In `tokenize`, extend the single-char punctuation set to include brackets:
```ts
if ('+-*/%<>!()?:,[]'.includes(c)) { toks.push({ k: c }); i++; continue; }
```
- [ ] **Step 3: Parser — array literals + postfix indexing**
Replace `primary()` so that after producing a base node it consumes any chain of `[ expr ]`, and add the `[ … ]` literal:
```ts
function atom(): Node {
const t = peek();
if (!t) throw new Error('unexpected end of expression');
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
if (t.k === '[') {
eat('[');
const items: Node[] = [];
if (peek()?.k !== ']') {
items.push(ternary());
while (peek()?.k === ',') { eat(','); items.push(ternary()); }
}
eat(']');
return { t: 'arr', items };
}
if (t.k === 'sig') {
eat();
const raw = t.v!;
const idx = raw.indexOf(':');
const ds = idx < 0 ? raw : raw.slice(0, idx);
const name = idx < 0 ? '' : raw.slice(idx + 1);
return { t: 'sig', ds, name };
}
if (t.k === 'ident') {
eat();
const id = t.v!;
if (id === 'true') return { t: 'num', v: 1 };
if (id === 'false') return { t: 'num', v: 0 };
if (peek()?.k === '(') {
eat('(');
const args: Node[] = [];
if (peek()?.k !== ')') {
args.push(ternary());
while (peek()?.k === ',') { eat(','); args.push(ternary()); }
}
eat(')');
return { t: 'call', fn: id, args };
}
return { t: 'var', name: id };
}
if (t.k === '(') { eat('('); const e = ternary(); eat(')'); return e; }
throw new Error(`unexpected token '${t.k}' in expression`);
}
function primary(): Node {
let n = atom();
while (peek()?.k === '[') {
eat('[');
const i = ternary();
eat(']');
n = { t: 'index', a: n, i };
}
return n;
}
```
(`unary` still calls `primary`; no other parser change.)
- [ ] **Step 4: Evaluator — return `ArrVal`, add array funcs + indexing**
Replace the evaluation section. Helpers `asNum` (coerce to number, throw on array) and `asArr` (require array) gate type errors.
```ts
import type { ArrVal } from './arraypolicy';
export type Resolver = (ds: string, name: string) => ArrVal;
function asNum(v: ArrVal): number {
if (typeof v !== 'number') throw new Error('expected a number, got an array');
return v;
}
function asArr(v: ArrVal): ArrVal[] {
if (!Array.isArray(v)) throw new Error('expected an array, got a number');
return v;
}
// resolve a possibly-negative index against length
function idx(i: number, len: number): number {
const k = Math.trunc(i) < 0 ? len + Math.trunc(i) : Math.trunc(i);
if (k < 0 || k >= len) throw new Error(`index ${i} out of range (len ${len})`);
return k;
}
const ARR_FUNCS: Record<string, (a: ArrVal[]) => ArrVal> = {
len: a => asArr(a[0]).length,
sum: a => asArr(a[0]).reduce((s, x) => s + asNum(x), 0),
mean: a => { const r = asArr(a[0]); return r.length ? r.reduce((s, x) => s + asNum(x), 0) / r.length : 0; },
// min/max: scalar-variadic OR single-array — see ev() dispatch below
slice: a => { const r = asArr(a[0]); const s = a[1] === undefined ? 0 : asNum(a[1]); const e = a[2] === undefined ? r.length : asNum(a[2]); return r.slice(s, e); },
concat: a => asArr(a[0]).concat(asArr(a[1])),
reverse: a => asArr(a[0]).slice().reverse(),
sort: a => asArr(a[0]).slice().sort((x, y) => asNum(x) - asNum(y)),
scale: a => asArr(a[0]).map(x => asNum(x) * asNum(a[1])),
add: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) + asNum(y[k])); return o; },
sub: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) - asNum(y[k])); return o; },
push: a => asArr(a[0]).concat([a[1]]),
set: a => { const r = asArr(a[0]).slice(); r[idx(asNum(a[1]), r.length)] = a[2]; return r; },
insert: a => { const r = asArr(a[0]).slice(); const k = Math.max(0, Math.min(r.length, Math.trunc(asNum(a[1])))); r.splice(k, 0, a[2]); return r; },
remove: a => { const r = asArr(a[0]).slice(); r.splice(idx(asNum(a[1]), r.length), 1); return r; },
pop: a => { const r = asArr(a[0]).slice(); r.pop(); return r; },
shift: a => { const r = asArr(a[0]).slice(); r.shift(); return r; },
indexOf: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return k; return -1; },
contains: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return 1; return 0; },
fill: a => new Array(Math.max(0, Math.trunc(asNum(a[0])))).fill(a[1]),
};
function ev(n: Node, R: Resolver): ArrVal {
switch (n.t) {
case 'num': return n.v;
case 'arr': return n.items.map(it => ev(it, R));
case 'sig': return R(n.ds, n.name);
case 'var': return R('local', n.name);
case 'index': return asArr(ev(n.a, R))[idx(asNum(ev(n.i, R)), asArr(ev(n.a, R)).length)];
case 'un': return n.op === '-' ? -asNum(ev(n.a, R)) : (asNum(ev(n.a, R)) === 0 ? 1 : 0);
case 'tern': return asNum(ev(n.c, R)) !== 0 ? ev(n.a, R) : ev(n.b, R);
case 'call': {
const args = n.args.map(a => ev(a, R));
// min/max keep scalar-variadic form, plus 1-arg array form
if ((n.fn === 'min' || n.fn === 'max') && !(args.length === 1 && Array.isArray(args[0]))) {
const nums = args.map(asNum);
return n.fn === 'min' ? Math.min(...nums) : Math.max(...nums);
}
if (n.fn === 'min' || n.fn === 'max') {
const r = asArr(args[0]).map(asNum);
return n.fn === 'min' ? Math.min(...r) : Math.max(...r);
}
const af = ARR_FUNCS[n.fn];
if (af) return af(args);
const sf = SCALAR_FUNCS[n.fn];
if (sf) return sf(args.map(asNum));
throw new Error(`unknown function '${n.fn}'`);
}
case 'bin': {
const a = asNum(ev(n.a, R)), b = asNum(ev(n.b, R));
switch (n.op) {
case '+': return a + b; case '-': return a - b; case '*': return a * b;
case '/': return a / b; case '%': return a % b;
case '<': return a < b ? 1 : 0; case '<=': return a <= b ? 1 : 0;
case '>': return a > b ? 1 : 0; case '>=': return a >= b ? 1 : 0;
case '==': return a === b ? 1 : 0; case '!=': return a !== b ? 1 : 0;
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
case '||': return (a !== 0 || b !== 0) ? 1 : 0;
default: throw new Error(`unknown operator '${n.op}'`);
}
}
}
}
```
Rename the existing `FUNCS` table to `SCALAR_FUNCS` (same entries: abs/min/max/sqrt/floor/ceil/round/sign/pow/log/exp/sin/cos) — but **remove** `min`/`max` from it since they are now handled in the `call` dispatch above.
- [ ] **Step 5: Public functions — split `evalValue` / `evalExpr`**
```ts
export function evalValue(src: string, resolve: Resolver): ArrVal {
return ev(parseCached(src), resolve);
}
export function evalExpr(src: string, resolve: Resolver): number {
try {
const v = ev(parseCached(src), resolve);
return typeof v === 'number' ? v : NaN;
} catch {
return NaN;
}
}
```
`evalBool` keeps calling `evalExpr` (array → NaN → false, acceptable). Update `collectRefs`'s `walk` switch to also recurse the new nodes:
```ts
case 'arr': n.items.forEach(walk); break;
case 'index': walk(n.a); walk(n.i); break;
```
- [ ] **Step 6: Audit callers of `Resolver`/`evalExpr`**
Run: `grep -rn "Resolver\|evalExpr\|evalValue" web/src` and confirm every resolver implementation can return `ArrVal` (returning a plain `number` still satisfies `ArrVal`). The write/condition callers that need a number keep using `evalExpr`; only array-targeting nodes (Task 4) use `evalValue`.
- [ ] **Step 7: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 8: Commit**
```bash
git add web/src/lib/expr.ts
git commit -m "feat(logic): array-aware expression engine (literals, indexing, array funcs)"
```
---
## Task 4: Array values + sizing in `localstate.ts`
**Files:**
- Modify: `web/src/lib/localstate.ts`
**Interfaces:**
- Consumes: `parseInitialArray`, `applySizing`, `ArrVal` from `./arraypolicy`; `StateVar` from `./types`.
- Produces: `writeLocalState(name, value, sv?)` — when `sv` is an array declaration, `applySizing` is enforced; `initLocalState` instantiates array locals from `parseInitialArray`; metadata carries `elem`/`sizing`/`capacity`.
- Produces: `export function declaredVar(name): StateVar | undefined` — lookup used by `logic.ts` node handlers to know a local's sizing policy.
- [ ] **Step 1: Track declarations + array init**
Add a `decls = new Map<string, StateVar>()` populated in `initLocalState`; extend `coerce` for `type==='array'`:
```ts
import { parseInitialArray, applySizing, type ArrVal } from './arraypolicy';
const decls = new Map<string, StateVar>();
export function declaredVar(name: string): StateVar | undefined { return decls.get(name); }
function coerce(v: StateVar): any {
switch (v.type) {
case 'bool': return v.initial === 'true' || v.initial === '1';
case 'string': return v.initial;
case 'array': return parseInitialArray(v);
default: { const n = parseFloat(v.initial); return isNaN(n) ? 0 : n; }
}
}
```
In `initLocalState`, `decls.set(v.name, v)` before publishing, and extend the metadata object with `elem: v.elem, sizing: v.sizing, capacity: v.capacity` (add these optional fields to `SignalMeta` in `types.ts`).
- [ ] **Step 2: Enforce sizing on write**
```ts
export function writeLocalState(name: string, value: any): void {
const sv = decls.get(name);
let v = value;
if (sv?.type === 'array' && Array.isArray(value)) v = applySizing(value as ArrVal[], sv);
valueW(name).set({ value: v, quality: 'good', ts: new Date().toISOString() });
}
```
- [ ] **Step 3: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 4: Commit**
```bash
git add web/src/lib/localstate.ts web/src/lib/types.ts
git commit -m "feat(logic): array local init + sizing enforcement in localstate"
```
---
## Task 5: Array action nodes + accumulate/export unification in `logic.ts`
**Files:**
- Modify: `web/src/lib/types.ts` (`LogicNodeKind` union — add `'action.array.push' | 'action.array.set' | 'action.array.remove' | 'action.array.pop' | 'action.array.clear'`)
- Modify: `web/src/lib/logic.ts`
**Interfaces:**
- Consumes: `evalValue`, `evalExpr` from `./expr`; `writeLocalState`, `getLocalValueStore`, `declaredVar` from `./localstate`; `applySizing` from `./arraypolicy`.
- Produces: node handlers for the five `action.array.*` kinds; `accumulate`→push and `clear`→array.clear aliasing; export-by-index with custom header labels; `ensureArrayDecls(graph)` auto-declare migration called from `load()`.
- [ ] **Step 1: Read current array machinery**
Run: `grep -n "arrays\|accumulate\|action.export\|action.clear\|runNode\|case 'action" web/src/lib/logic.ts` to locate the dispatch switch and the legacy `arrays:Map` (around the lines noted in the design's code map: store ~225, accumulate ~614, export ~626, clear ~632, exportArrays ~733).
- [ ] **Step 2: Replace the `{t,v}` store with array-local reads/writes**
Array locals are the single source of truth. Implement a helper to read the current array value of a local:
```ts
import { get } from './store';
import { getLocalValueStore, writeLocalState, declaredVar } from './localstate';
import { applySizing, type ArrVal } from './arraypolicy';
function curArray(name: string): ArrVal[] {
const v = get(getLocalValueStore(name)).value;
return Array.isArray(v) ? (v as ArrVal[]) : [];
}
```
(If `store.ts` lacks a synchronous `get`, read via a one-shot subscribe; confirm in Step 1.)
- [ ] **Step 3: Implement the five node handlers**
In the node dispatch switch:
```ts
case 'action.array.push': {
const arr = curArray(p.array);
writeLocalState(p.array, [...arr, evalValue(p.expr, ctx.resolve)]);
break;
}
case 'action.array.set': {
const arr = curArray(p.array).slice();
const path = String(p.index ?? '').split(',').map(s => Math.trunc(evalExpr(s, ctx.resolve)));
setPath(arr, path, evalValue(p.expr, ctx.resolve)); // setPath: nested index assignment, defined below
writeLocalState(p.array, arr);
break;
}
case 'action.array.remove': {
const arr = curArray(p.array).slice();
const i = Math.trunc(evalExpr(p.index, ctx.resolve));
const k = i < 0 ? arr.length + i : i;
if (k >= 0 && k < arr.length) arr.splice(k, 1);
writeLocalState(p.array, arr);
break;
}
case 'action.array.pop': {
const arr = curArray(p.array).slice(); arr.pop();
writeLocalState(p.array, arr);
break;
}
case 'action.array.clear': {
const sv = declaredVar(p.array);
writeLocalState(p.array, sv ? applySizing([], sv) : []); // fixed → zero-refill via applySizing
break;
}
```
Add `setPath`:
```ts
function setPath(arr: ArrVal[], path: number[], v: ArrVal): void {
let cur: ArrVal[] = arr;
for (let d = 0; d < path.length - 1; d++) {
let k = path[d]; if (k < 0) k = cur.length + k;
if (!Array.isArray(cur[k])) cur[k] = [];
cur = cur[k] as ArrVal[];
}
let last = path[path.length - 1]; if (last < 0) last = cur.length + last;
cur[last] = v;
}
```
- [ ] **Step 4: Alias accumulate/clear and rewrite export**
In the dispatch switch, make the legacy kinds delegate:
```ts
case 'action.accumulate': // legacy alias → push
{ const arr = curArray(p.array); writeLocalState(p.array, [...arr, evalValue(p.expr, ctx.resolve)]); }
break;
case 'action.clear': // legacy alias → array.clear
{ const sv = declaredVar(p.array); writeLocalState(p.array, sv ? applySizing([], sv) : []); }
break;
```
Rewrite `action.export` to read array locals by column and emit index-aligned CSV with custom headers. Parse `p.columns` as `[{array, label}]`; header row uses `label || array`; row `r` joins `col[r] ?? ''`:
```ts
case 'action.export': {
const cols = JSON.parse(p.columns || '[]') as { array: string; label?: string }[];
const data = cols.map(c => curArray(c.array));
const rows = Math.max(0, ...data.map(d => d.length));
const header = cols.map(c => csvCell(c.label || c.array)).join(',');
const lines = [header];
for (let r = 0; r < rows; r++) {
lines.push(data.map(d => (r < d.length ? csvCell(String(d[r])) : '')).join(','));
}
downloadCsv(lines.join('\n'), p.filename || 'export.csv'); // reuse existing blob-download helper
break;
}
```
(Keep/rename the existing CSV-escape and blob-download helpers as `csvCell`/`downloadCsv`; drop `exportArrays`/`interpAt` and the `align` param handling.)
- [ ] **Step 5: Auto-declare migration in `load()`**
After the graph is loaded but before subscriptions, ensure any array referenced by an array node / accumulate / export but **not** declared gets a dynamic numeric array local:
```ts
function ensureArrayDecls(graph: LogicGraph, vars: StateVar[]): StateVar[] {
const have = new Set(vars.map(v => v.name));
const out = vars.slice();
const need = (name: string) => {
if (name && !have.has(name)) { have.add(name); out.push({ name, type: 'array', elem: 'number', sizing: 'dynamic', initial: '' }); }
};
for (const n of graph.nodes) {
if (n.kind === 'action.accumulate' || n.kind === 'action.clear' || n.kind.startsWith('action.array.')) need(n.params.array);
if (n.kind === 'action.export') { try { (JSON.parse(n.params.columns || '[]') as any[]).forEach(c => need(c.array)); } catch {} }
}
return out;
}
```
Call this so the resulting list is passed to `initLocalState` (wherever `load()` currently calls it; if `load()` doesn't own statevars, thread the merged list through the same path the panel uses to init local state).
- [ ] **Step 6: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 7: Commit**
```bash
git add web/src/lib/logic.ts web/src/lib/types.ts
git commit -m "feat(logic): array action nodes + accumulate/export unification + migration"
```
---
## Task 6: Persist array `<statevar>` attributes (`xml.ts`)
**Files:**
- Modify: `web/src/lib/xml.ts` (statevar read ~lines 67-77 and the corresponding write path)
**Interfaces:**
- Produces: `<statevar>` round-trips `elem`, `sizing`, `capacity` in addition to existing attrs, only emitting them when present.
- [ ] **Step 1: Write path — emit array attrs**
Where statevars are serialized, append the optional attrs:
```ts
function statevarXml(v: StateVar): string {
const a = [`name="${esc(v.name)}"`, `type="${v.type ?? 'number'}"`, `initial="${esc(v.initial)}"`];
if (v.unit) a.push(`unit="${esc(v.unit)}"`);
if (v.low !== undefined) a.push(`low="${v.low}"`);
if (v.high !== undefined) a.push(`high="${v.high}"`);
if (v.type === 'array') {
if (v.elem) a.push(`elem="${v.elem}"`);
if (v.sizing) a.push(`sizing="${v.sizing}"`);
if (v.capacity !== undefined) a.push(`capacity="${v.capacity}"`);
}
return `<statevar ${a.join(' ')}/>`;
}
```
(Adapt to the file's existing serialization style — match how `unit`/`low`/`high` are currently emitted.)
- [ ] **Step 2: Read path — parse array attrs**
Where `<statevar>` is parsed into a `StateVar`, add:
```ts
elem: el.getAttribute('elem') as any || undefined,
sizing: el.getAttribute('sizing') as any || undefined,
capacity: el.hasAttribute('capacity') ? Number(el.getAttribute('capacity')) : undefined,
```
- [ ] **Step 3: Build + round-trip sanity (manual)**
Run: `make frontend`. Then in Task 9's smoke test confirm an array statevar survives save→reload.
- [ ] **Step 4: Commit**
```bash
git add web/src/lib/xml.ts
git commit -m "feat(logic): round-trip array statevar attributes in panel XML"
```
---
## Task 7: Editor — array declaration form + array nodes
**Files:**
- Modify: `web/src/LogicEditor.tsx` (the `LocalVars` subcomponent + the palette node list + the inspector)
- Modify: `web/src/lib/flowDebug.ts` (compact array badge formatting)
**Interfaces:**
- Consumes: `StateVar` shape (Task 1), the new node kinds (Task 5).
- Produces: UI to declare array locals and place/inspect `action.array.*` nodes; debug badges that stringify arrays compactly.
- [ ] **Step 1: `LocalVars` array form**
In the `LocalVars` subcomponent, when the type select value is `array`, reveal: an `elem` select (number/bool/array), a `sizing` select (dynamic/capped/fixed), a `capacity` number input (shown for capped/fixed), and the existing `initial` text field repurposed as a JSON literal with inline `JSON.parse` validation (red hint on parse error). Persist via the existing `onStateVarsChange` path.
- [ ] **Step 2: Palette + inspector for array nodes**
Add the five `action.array.*` kinds to the Actions palette group (label/icon consistent with existing entries). In the inspector `switch`, add cases rendering: `array` (a select of declared array-local names), and `expr`/`index` fields using the existing `ExprField` (which already runs `checkExpr`). `action.array.set` shows both `index` (path, comma-separated) and `expr`.
- [ ] **Step 3: Compact array badges**
In `flowDebug.ts`, where a node value is stringified for the badge, format arrays as e.g. `[1, 2, 3, …](n=N)` truncated to the first ~3 elements:
```ts
export function fmtBadge(v: unknown): string {
if (Array.isArray(v)) {
const head = v.slice(0, 3).map(x => Array.isArray(x) ? '[…]' : String(x)).join(', ');
return `[${head}${v.length > 3 ? ', …' : ''}](n=${v.length})`;
}
return typeof v === 'number' ? String(+v.toFixed(4)) : String(v);
}
```
Wire `fmtBadge` into the existing badge render site (replace the inline number formatting).
- [ ] **Step 4: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 5: Commit**
```bash
git add web/src/LogicEditor.tsx web/src/lib/flowDebug.ts
git commit -m "feat(logic): array local declaration form + array nodes + array debug badges"
```
---
## Task 8: Widgets read array locals
**Files (verify exact paths first):**
- Run: `ls web/src/widgets` and `grep -rln "bitset\|MultiLed\|multi-led\|TableWidget\|PlotWidget" web/src/widgets`
- Modify: the plot widget, table widget, multi-LED/bitset widget.
**Interfaces:**
- Consumes: a bound local whose `SignalValue.value` may be `ArrVal[]`; metadata `elem`/`sizing`/`capacity` from `getLocalMetaStore`.
- [ ] **Step 1: Plot — accept a 1-D numeric array local as a waveform**
In the plot widget's value-ingest path, when a bound signal's value is a numeric array, feed it through the **same** code path already used for EPICS `float64[]` waveform samples (multidimensional/FFT/waterfall). Nested arrays → render the existing "unsupported" placeholder. (Locate the waveform branch via `grep -n "Array.isArray\|waveform\|float64" web/src/widgets/PlotWidget.tsx`.)
- [ ] **Step 2: Table — array source mode**
Add an `array` source mode (config flag) that, when the bound value is an array, renders one row per element (index + value with the per-signal value format). For `elem:'array'` (2-D), rows = outer index, configured columns map to inner positions. Scalars → existing multi-signal behavior.
- [ ] **Step 3: Multi-LED — array source mode**
Add an `array` source mode: render one LED per element, lit by element truthiness; LED count tracks array length live; when meta `elem==='bool'`, use the declared on/off labels.
- [ ] **Step 4: Build**
Run: `make frontend`
Expected: builds clean.
- [ ] **Step 5: Commit**
```bash
git add web/src/widgets
git commit -m "feat(widgets): array source modes for plot, table, multi-LED"
```
---
## Task 9: Manual smoke verification + docs
**Files:**
- Modify: `TODO.md` (do NOT check the box yet — Phase 2 Go port still pending; add a sub-note that panel-logic arrays are done)
- Modify: `docs/TECHNICAL_SPEC.md` (document array statevars + array expression functions + the export change)
- [ ] **Step 1: Build the whole app**
Run: `make all`
Expected: frontend + backend build clean.
- [ ] **Step 2: Manual smoke checklist** (run `go run ./cmd/uopi`, open a panel in edit mode → Logic tab)
- Declare a `capped` array `hist` capacity 5; add a `trigger.timer``action.array.push{array:hist, expr:{ds:stub:sine_1hz}}`; in view mode confirm `hist` rings at 5 elements (debug badge shows `[…](n=5)`).
- Add `action.write{target: avg, expr: mean(hist)}` (scalar local `avg`); confirm it tracks.
- Indexing: `action.write{target: last, expr: hist[-1]}` updates to the newest sample.
- `fixed` array `grid` capacity 4 initial `[0,0,0,0]`; `action.array.set{array:grid, index:"2", expr:42}`; confirm element 2 = 42 and length stays 4.
- Save the panel, reload it, confirm the array statevars + nodes persist (Task 6).
- Legacy: open/confirm an existing panel using `action.accumulate`/`action.export` still records and exports CSV (now index-aligned; header uses custom labels).
- Widgets: bind `hist` to a plot (waveform), a table (one row per element), and a multi-LED (one LED per element) and confirm they render and update live.
- [ ] **Step 3: Commit docs**
```bash
git add TODO.md docs/TECHNICAL_SPEC.md
git commit -m "docs(logic): document panel-logic array locals"
```
---
## Self-Review Notes (spec coverage)
- Data model (spec §2) → Task 1 + Task 2 (`arraypolicy`).
- Expression engine (spec §3) → Task 3 (all functions, indexing, literals, negative index, errors, ref-collection).
- Mutation nodes + accumulate/export unification + migration (spec §4) → Task 5.
- Persistence panel XML (spec §5) → Task 6. (Control-logic Go persistence = Phase 2.)
- Editor UI + debug badges (spec §6, panel side) → Task 7. (Control editor = Phase 2.)
- Widgets (spec §7) → Task 8.
- Testing (spec §8): TS has no runner → manual smoke (Task 9); the authoritative automated suite + cross-engine parity table is **Phase 2 (Go)**.
**Deferred to Phase 2 (separate plan):** all of `internal/controllogic` (boxed `value` in `expr.go`, `Graph.StateVars`, `getLocal`/`setLocal` policy, JSON round-trip, config-apply/snapshot boxing), `ControlLogicEditor.tsx` LocalVars parity, the Go debug-event array payload, and the full Go test + parity suite.
@@ -0,0 +1,266 @@
# Design: Local array values for the node-editor flow engines
**Date:** 2026-06-24
**Status:** Approved (design phase)
**TODO refs:** "Logic editor → add full support to local array values: dynamic,
dynamic but capped max, fixed size etc; array functions should work with new
local array" and "Control loop → add full support to server side array values".
## 1. Goal & scope
Add first-class **array-valued local variables** to both flow engines:
- **Panel logic** (client TS): `web/src/lib/logic.ts`, `LogicEditor.tsx`,
`web/src/lib/types.ts`, `web/src/lib/expr.ts`, `web/src/lib/localstate.ts`,
`web/src/lib/xml.ts`.
- **Control logic** (server Go): `internal/controllogic/` (`model.go`,
`engine.go`, `expr.go`), editor `web/src/ControlLogicEditor.tsx`.
**Build order:** design both together; implement **panel logic (TS) first**,
then port the same design to control logic (Go). The panel `StateVar` schema is
the richer reference and doing TS first de-risks the expression-language change
before the Go port.
**In scope for v1:** declaration + sizing policies, an array-aware expression
language (shared by both engines), array mutation nodes, persistence, **and
widget binding** (multi-LED/bitset, table, plot read array locals).
## 2. Data model — array local declaration
A new array kind on the existing `StateVar` (TS `types.ts`; mirrored as a Go
struct in control logic, which gains state-var declarations for the first time):
```
StateVar {
name: string
type: 'number' | 'bool' | 'string' | 'array' // 'array' is new
initial: string // arrays: JSON literal e.g. "[0,0,0]" / "[[1,2],[3,4]]", or "" = empty/zero-fill
unit?, low?, high? // existing scalar fields
// present only when type === 'array':
elem?: 'number' | 'bool' | 'array' // element type; 'array' ⇒ nested (recursive, arbitrary depth, jagged allowed)
sizing?: 'dynamic' | 'capped' | 'fixed' // default 'dynamic'
capacity?: number // required for capped/fixed
}
```
Semantics:
- **dynamic** — unbounded, guarded by a global safety cap (1e6 elements) to
prevent runaway growth.
- **capped** — `capacity` max; pushing past full **drops the oldest** element
(ring / FIFO).
- **fixed** — exactly `capacity` slots. Initialised from the `initial` literal
(truncated / zero-padded to `capacity`), else **zero-filled**. `push` is a
no-op (write via index/set); `clear` zero-refills rather than emptying.
- **initial** — non-empty JSON literal is parsed and used; empty ⇒ dynamic and
capped start `[]`, fixed starts zero-filled.
- `elem: 'bool'` is **display metadata only**. Runtime leaves are numeric
`1`/`0` (consistent with the engine's existing "booleans are 1/0" rule);
widgets use the declaration to render on/off.
**Declaration-time validation:** capped/fixed require `capacity >= 1`;
`initial`, if non-empty, must parse as JSON and match the declared element
type / nesting. Errors surface inline in the editor.
## 3. Expression engine (`expr.ts` + `expr.go`)
**Value model (tagged union — "Approach 1"):**
- TS: `type EvalValue = number | EvalValue[]`. The evaluator returns
`EvalValue`; functions/indexing type-check at runtime and throw on misuse
(caught → node error badge).
- Go: a boxed `value{ num float64; arr []value; isArr bool }`. `Resolver`
returns `value`; every `*Node.eval` returns `value` (a contained, mechanical
refactor of `expr.go`, which today returns `float64`). The `float64` leaf
stays the fast path.
**New syntax (both parsers):**
- **Array literal:** `[a, b, c]`, nested `[[1,2],[3,4]]`.
- **Indexing:** postfix `expr[expr]`, chainable `a[i][j]`. Index rounds to int;
**negative index counts from the end** (`a[-1]` = last); out-of-range → node
error.
**Functions** — added to the existing `abs/min/max/sqrt/...` table. All the
read/transform functions are **pure** (return new values; never mutate a local):
| Function | Result | Meaning / notes |
|---|---|---|
| `len(a)` | number | element count (top level) |
| `sum(a)`, `mean(a)`, `min(a)`, `max(a)` | number | over a 1-D numeric array; error if elements are arrays |
| `slice(a, s, e)` | array | subrange, `e` exclusive, negative indices allowed |
| `concat(a, b)` | array | join |
| `reverse(a)` | array | |
| `sort(a)` | array | ascending numeric |
| `scale(a, k)` | array | element-wise `a[i]*k` |
| `add(a, b)`, `sub(a, b)` | array | element-wise pairwise; length = min(len a, len b) |
| `push(a, v)` | array | copy with `v` appended |
| `set(a, i, v)` | array | copy with element `i` replaced (negative `i` ok) |
| `insert(a, i, v)` | array | copy with `v` inserted at `i` |
| `remove(a, i)` | array | copy without element `i` |
| `pop(a)` | array | copy without the last element (read it with `a[-1]`) |
| `shift(a)` | array | copy without the first element (read with `a[0]`) |
| `indexOf(a, v)` | number | first index of `v`, else `-1` |
| `contains(a, v)` | number | `1`/`0` |
| `fill(n, v)` | array | new length-`n` array of `v` |
- `min`/`max` keep their existing **scalar variadic** form (`min(x,y,z)`) and
gain a 1-arg **array** form (`min(a)`) — dispatch on arg count + type.
- **Resolution:** a bare identifier naming an array local returns the whole
array value; `{ds:sig}` waveform signals (EPICS `float64[]`) become first-class
array values usable by every function above.
**Purity & persistence interplay:** the mutator-named functions
(`push/set/insert/remove/pop/shift`) are **immutable transforms** — they return a
new array and do not touch the local. You persist a result by writing it back;
the **sizing policy is enforced at store time** in `writeLocalState` (TS) /
`setLocal` (Go) whenever the write target is a typed array local (ring-drop for
capped, clamp / no-op for fixed). The mutation nodes (§4) are convenient, visible
sugar for "store with policy".
**Errors** (e.g. `sum` of nested array, indexing a scalar, `add` of non-arrays)
throw in the evaluator and surface as the node's error reason via the existing
`checkExpr` validation + runtime-catch / badge path.
**Ref-collection** (`collectRefs` / `CollectRefs`) walks the new literal/index
AST so subscriptions still discover every `{ds:sig}` inside array expressions.
## 4. Mutation nodes + accumulate/export unification
**New action nodes** (panel `LogicNodeKind`, mirrored in Go control-logic kinds):
- `action.array.push{array, expr}` — append `eval(expr)`; sizing-policy aware
(ring-drop if capped; no-op if fixed).
- `action.array.set{array, index, expr}` — store at `eval(index)`. Supports
**nested targets via an index path**: `index = "i, j"``a[i][j]`. Negative
indices allowed; out-of-range → node error. (This is the imperative
path-assignment style.)
- `action.array.remove{array, index}` — remove element at index (in place).
- `action.array.pop{array}` — remove last element (in place).
- `action.array.clear{array}` — empty (dynamic/capped) or zero-refill (fixed).
These are the sizing-policy-aware, in-place counterparts to the pure expression
functions.
**Unification of the existing `{t,v}` array system (decision: unify):**
- `action.accumulate{array, expr}` → reframed as `action.array.push` (append,
policy-enforced). Old kind **kept as a compile alias** so saved panels run.
- `action.clear{array}``action.array.clear` (alias retained).
- `action.export{columns, align, filename}` → serializes **array locals** by
column. Array locals are plain numeric (no per-sample `t`), so **time-based
alignment (`common`/`any`/`interpolate`) is dropped**; columns are emitted
**side-by-side by index** (ragged columns padded blank). The `align` param is
ignored and hidden in the inspector. To keep a timestamp column, push
`{sys:time}` into a parallel array local and add it as a column.
- **Custom column names:** each export column keeps its `label`, surfaced as an
**editable header name** in the inspector (default = array-local name); the CSV
header row uses the chosen names.
**Migration (non-destructive, at engine `load()`):** any
`accumulate`/`clear`/`export` node referencing an array name with **no** matching
`StateVar` declaration triggers an **auto-declared dynamic numeric array local**
of that name. No file rewrite.
## 5. Persistence
**Panel logic (XML, `xml.ts`):** `<statevar>` gains optional array attributes,
written only for arrays:
```xml
<statevar name="hist" type="array" elem="number" sizing="capped" capacity="100" initial=""/>
<statevar name="grid" type="array" elem="array" sizing="fixed" capacity="4" initial="[[0,0],[0,0]]"/>
```
Round-trips through the existing verbatim-body store (no Go change for panels).
New nodes serialize via the existing `<node><param/></node>` mechanism.
**Control logic (Go, `model.go`):** control logic has **no** state-var
declarations today (`locals` is an untyped `map[string]float64`). Additions:
- `Graph` gains `StateVars []StateVar` (Go struct mirroring the TS shape:
`Name, Type, Elem, Sizing string; Capacity int; Initial, Unit string; Low,
High float64`), serialized in `controllogic.json`.
- `compiledGraph.locals` changes from `map[string]float64` to
`map[string]value`, initialised from `StateVars` (applying sizing/initial) at
compile time.
- `getLocal`/`setLocal` operate on `value`; `setLocal` enforces sizing policy.
Config-apply / snapshot paths that read/write locals as `float64` box/unbox.
**Versioning:** control-logic graphs are already git-style versioned; the new
`StateVars` field rides along in each revision (no diff-engine change — just more
JSON).
## 6. Editor UI + debug/live badges
**Panel `LogicEditor.tsx`:** the `LocalVars` palette subcomponent gains an array
declaration form (type=array reveals element-type / sizing / capacity / `initial`
JSON with inline validation). New array action nodes added to the Actions palette
group with inspectors (array name + expr/index fields, reusing `ExprField` with
array-aware `checkExpr`).
**Control `ControlLogicEditor.tsx`:** control logic has **no** local-var
declaration UI today. Add a `LocalVars` panel mirroring the panel editor (same
component, driven by `Graph.StateVars`) plus the array nodes in its palette. This
brings control-logic locals to parity — scalars *and* arrays become declarable
there for the first time.
**Debug/live badges (`flowDebug.ts` + Go `DebugObserver` / synthetic trace):**
array node values render as a truncated literal, e.g. `[1, 2, 3, …](n=100)`. The
Go debug event payload (`debugNode`) and the synthetic trace already serialize a
value — extended to carry array JSON; the badge formatter stringifies arrays
compactly.
## 7. Widgets reading array locals
The `ds:'local'` plumbing already routes through `stores.ts`/`ws.ts`; the change
is that a local's `SignalValue.value` can be an array (number / nested), held and
initialised per panel instance by `localstate.ts`. No new widget types — new
source modes on three existing widgets:
- **Plot** — a 1-D numeric array local binds as a **waveform sample** (same path
EPICS `float64[]` waveforms already use for multidimensional/FFT/waterfall).
Each engine tick that rewrites the array updates the trace. Nested arrays show
"unsupported shape".
- **Table widget** — gains an **array source mode**: bound to one array local,
renders **one row per element** (index + value, per-signal value-format applied).
For an `elem:'array'` (2-D) local, rows are indices and the configured columns
map to inner-array positions. Falls back to multi-signal mode for scalars.
- **Multi-LED / bitset** — gains an **array source mode**: one LED per element,
lit per element truthiness; when `elem:'bool'`, on/off labels come from the
declaration. Existing integer-bitset mode unchanged.
`getLocalMetaStore` carries `elem`/`sizing`/`capacity` so widgets self-configure
(e.g. multi-LED LED count = array length, growing/shrinking live for dynamic
arrays).
## 8. Testing
- **TS unit (expr):** literals; indexing (negative, nested, out-of-range error);
every new function incl. type-error cases; sizing-policy enforcement on store
(ring drop-oldest, fixed no-op / zero-refill); accumulate→push migration; CSV
export by-index with custom headers.
- **Go unit (`internal/controllogic`):** port of the expr suite (boxed `value`,
all functions/indexing/errors); `StateVars` init from declarations; `setLocal`
policy enforcement; JSON round-trip of `StateVars`; config-apply/snapshot with
boxed locals.
- **Cross-engine parity:** a `(expr, expected)` table asserted identical in both
`expr.ts` and `expr.go` to keep them in lockstep.
- **Widgets:** light component tests for the new array source modes if widget
tests exist; else manual verification.
- **Gates:** `gofmt`, `go vet`, `go test ./... -race`, frontend typecheck/build.
## 9. Risks
- **Go `expr.go` refactor (`float64` → boxed `value`):** touches every node's
`eval` and the `Resolver`. Mechanical but broad; the parity test guards
behavioral drift from `expr.ts`.
- **Backward compatibility of `accumulate`/`export`:** aliasing + auto-declared
locals keep old panels running, but the **dropped time-alignment in export** is
a behavioral change for any panel relying on `interpolate`/`common` align. Call
this out in release notes.
- **Two engines staying in lockstep:** the function set and semantics must match
exactly across TS and Go; the cross-engine parity fixture is the safeguard.
- **Hot-path purity:** array expression functions are evaluated repeatedly; they
must remain allocation-light and side-effect-free (mutation only at store time).
+7
View File
@@ -11,11 +11,18 @@ require (
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.66.10 // indirect
+65
View File
@@ -1,32 +1,97 @@
cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE=
cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
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=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
+601 -135
View File
@@ -1,32 +1,114 @@
// Package access implements uopi's global user-access policy: every user is
// trusted (full write) by default, while a configured blacklist can downgrade
// specific users to read-only or no access. It also resolves the per-request
// user identity and the user→group memberships defined in config.
// Package access implements uopi's role-based access policy. Access is granted
// through group memberships: every user belongs implicitly to the built-in
// "public" group (granting the baseline viewer role), and may additionally be
// assigned a higher role in any number of named groups. Roles form a cumulative
// ladder — viewer < operator < logic-editor < auditor < admin — where each level
// includes all powers below it. A user's effective capability is the highest
// role they hold across all their groups.
//
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
// layered on top of this in a later phase.
// Groups can be nested: a member of a parent group holds that role on every
// descendant group too (unless the descendant assigns them a different role),
// so an admin of an organisation group administers its sub-teams.
//
// As a bootstrap convenience, a policy with no role assignments at all is treated
// as fully open (everyone is admin), matching an unconfigured/dev deployment.
// Assigning any role switches to strict mode where unlisted users are viewers.
//
// The policy is seeded from the TOML config at startup but is runtime-mutable
// through the admin pane: once EnablePersistence is called, every mutation is
// written to a JSON sidecar ({storageDir}/access.json) which, when present on a
// later startup, becomes the source of truth (the TOML config is then only a
// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent
// use; the *Policy pointer is stable so existing shared-pointer wiring is
// preserved.
package access
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// Level is a global access level. Higher levels include lower ones.
// PublicGroup is the built-in group every user implicitly belongs to. It cannot
// be renamed or deleted and is always a top-level (parentless) group.
const PublicGroup = "public"
// Role is a cumulative access level granted by a group membership. Higher roles
// include every power of the lower ones.
type Role int
const (
// RoleViewer permits reads only (no signal writes). It is the baseline every
// user receives via the public group.
RoleViewer Role = iota
// RoleOperator adds signal writes (full HMI interaction).
RoleOperator
// RoleLogic adds editing panel and server-side control logic.
RoleLogic
// RoleAuditor adds viewing the audit log.
RoleAuditor
// RoleAdmin adds managing users, groups and access, and viewing server stats.
RoleAdmin
)
// RoleNames lists the role tokens from lowest to highest, for the admin UI.
var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"}
// String renders the role using the tokens accepted by ParseRole.
func (r Role) String() string {
switch r {
case RoleOperator:
return "operator"
case RoleLogic:
return "logiceditor"
case RoleAuditor:
return "auditor"
case RoleAdmin:
return "admin"
default:
return "viewer"
}
}
// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the
// least-privileged viewer.
func ParseRole(s string) Role {
switch strings.ToLower(strings.TrimSpace(s)) {
case "operator", "write", "operate", "rw":
return RoleOperator
case "logiceditor", "logic", "logic_editor", "editor":
return RoleLogic
case "auditor", "audit":
return RoleAuditor
case "admin", "administrator":
return RoleAdmin
default:
return RoleViewer
}
}
// Level is a coarse global access level retained for the rest of the codebase
// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a
// user's effective role: viewer → read-only, operator and above → write.
type Level int
const (
// LevelNone denies all access.
// LevelNone denies all access. Never produced by the role model, but kept so
// existing switch statements remain exhaustive.
LevelNone Level = iota
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
// LevelRead permits reads only.
LevelRead
// LevelWrite permits full access. This is the default for any user not blacklisted.
// LevelWrite permits full write access.
LevelWrite
)
// String renders the level using the same tokens accepted by ParseLevel and
// surfaced to the frontend via /api/v1/me.
// String renders the level using the tokens surfaced to the frontend via
// /api/v1/me.
func (l Level) String() string {
switch l {
case LevelNone:
@@ -38,87 +120,217 @@ func (l Level) String() string {
}
}
// ParseLevel maps a config string to a Level. Unknown values restrict to
// read-only, since the only reason to list a user is to limit them.
func ParseLevel(s string) Level {
switch strings.ToLower(strings.TrimSpace(s)) {
case "noaccess", "none", "no":
return LevelNone
case "readonly", "read", "ro":
return LevelRead
case "write", "readwrite", "rw", "full":
func roleToLevel(r Role) Level {
if r >= RoleOperator {
return LevelWrite
default:
}
return LevelRead
}
// group holds a group's parent (for nesting) and explicit per-user role
// assignments.
type group struct {
parent string
members map[string]Role
}
// Policy holds the resolved global access configuration. It is immutable after
// construction and safe for concurrent use.
// Policy holds the resolved role-based access configuration. It is safe for
// concurrent use; reads take a shared lock and admin mutations take an exclusive
// lock and persist to disk.
type Policy struct {
defaultUser string
blacklist map[string]Level // user → downgraded level
userGroups map[string][]string // user → groups they belong to
groupNames []string // all configured group names (sorted)
logicEditors map[string]bool // users + group names allowed to edit logic
auditReaders map[string]bool // users + group names allowed to view the audit log
mu sync.RWMutex
path string // access.json path; "" disables persistence
defaultUser string // immutable after construction/load
groups map[string]*group
}
// New builds a Policy. blacklist maps a username to a config level string;
// groups maps a group name to its member usernames. logicEditors optionally
// restricts who may edit panel/control logic (usernames or group names); empty
// means no restriction.
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors, auditReaders []string) *Policy {
// GroupSpec seeds one group at construction time: its name, optional parent, and
// explicit user→role assignments.
type GroupSpec struct {
Name string
Parent string
Members map[string]Role
}
// New builds a Policy from group specs. The built-in public group is always
// created. A spec listing the same user in multiple roles keeps the last one;
// callers should pass the highest intended role.
func New(defaultUser string, specs []GroupSpec) *Policy {
p := &Policy{
defaultUser: strings.TrimSpace(defaultUser),
blacklist: make(map[string]Level),
userGroups: make(map[string][]string),
logicEditors: make(map[string]bool),
auditReaders: make(map[string]bool),
groups: make(map[string]*group),
}
for _, e := range logicEditors {
e = strings.TrimSpace(e)
if e != "" {
p.logicEditors[e] = true
}
}
for _, e := range auditReaders {
e = strings.TrimSpace(e)
if e != "" {
p.auditReaders[e] = true
}
}
for user, lvl := range blacklist {
u := strings.TrimSpace(user)
if u == "" {
for _, s := range specs {
name := strings.TrimSpace(s.Name)
if name == "" {
continue
}
p.blacklist[u] = ParseLevel(lvl)
}
for g, members := range groups {
g = strings.TrimSpace(g)
if g == "" {
continue
}
p.groupNames = append(p.groupNames, g)
for _, m := range members {
m = strings.TrimSpace(m)
if m == "" {
continue
}
p.userGroups[m] = append(p.userGroups[m], g)
g := p.ensureGroupLocked(name)
g.parent = strings.TrimSpace(s.Parent)
for u, r := range s.Members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = r
}
}
sort.Strings(p.groupNames)
}
p.ensureGroupLocked(PublicGroup).parent = ""
p.normalizeParentsLocked()
return p
}
// GroupNames returns a copy of every configured user-group name, sorted.
func (p *Policy) GroupNames() []string {
out := make([]string, len(p.groupNames))
copy(out, p.groupNames)
return out
// ensureGroupLocked returns the named group, creating an empty one if needed.
// The caller must hold p.mu for writing (or be in construction).
func (p *Policy) ensureGroupLocked(name string) *group {
g, ok := p.groups[name]
if !ok {
g = &group{members: make(map[string]Role)}
p.groups[name] = g
}
return g
}
// normalizeParentsLocked drops parent references to missing groups or that would
// form a cycle, and forces the public group to be a root.
func (p *Policy) normalizeParentsLocked() {
for name, g := range p.groups {
if name == PublicGroup {
g.parent = ""
continue
}
if g.parent == "" {
continue
}
if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) {
g.parent = ""
}
}
}
// hasCycleLocked reports whether following parent links from start loops back.
func (p *Policy) hasCycleLocked(start string) bool {
seen := make(map[string]bool)
for cur := start; cur != ""; {
if seen[cur] {
return true
}
seen[cur] = true
g, ok := p.groups[cur]
if !ok {
return false
}
cur = g.parent
}
return false
}
// ── effective role ─────────────────────────────────────────────────────────
// configuredLocked reports whether any explicit role is assigned anywhere. An
// unconfigured policy is treated as fully open (bootstrap-safe).
func (p *Policy) configuredLocked() bool {
for _, g := range p.groups {
if len(g.members) > 0 {
return true
}
}
return false
}
// effectiveRoleLocked returns a user's highest role across all groups. Unlisted
// users (and anonymous callers) get the viewer baseline once the policy is
// configured; an unconfigured policy grants everyone admin.
func (p *Policy) effectiveRoleLocked(user string) Role {
if !p.configuredLocked() {
return RoleAdmin
}
best := RoleViewer
if user = strings.TrimSpace(user); user == "" {
return best
}
for _, g := range p.groups {
if r, ok := g.members[user]; ok && r > best {
best = r
}
}
return best
}
// ── persistence ────────────────────────────────────────────────────────────
// persisted is the on-disk schema for access.json.
type persisted struct {
DefaultUser string `json:"defaultUser"`
Groups map[string]persistedGroup `json:"groups"`
}
type persistedGroup struct {
Parent string `json:"parent"`
Members map[string]string `json:"members"` // user → role token
}
// EnablePersistence points the policy at {storageDir}/access.json. If the file
// exists its contents replace the TOML-seeded state (the file is the source of
// truth once written); otherwise the current state is kept and the file is only
// created on the first mutation.
func (p *Policy) EnablePersistence(storageDir string) error {
p.mu.Lock()
defer p.mu.Unlock()
p.path = filepath.Join(storageDir, "access.json")
data, err := os.ReadFile(p.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
var ps persisted
if err := json.Unmarshal(data, &ps); err != nil {
return err
}
p.defaultUser = strings.TrimSpace(ps.DefaultUser)
p.groups = make(map[string]*group)
for name, pg := range ps.Groups {
if name = strings.TrimSpace(name); name == "" {
continue
}
g := p.ensureGroupLocked(name)
g.parent = strings.TrimSpace(pg.Parent)
for u, token := range pg.Members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = ParseRole(token)
}
}
}
p.ensureGroupLocked(PublicGroup).parent = ""
p.normalizeParentsLocked()
return nil
}
// saveLocked atomically persists the current state when persistence is enabled.
func (p *Policy) saveLocked() error {
if p.path == "" {
return nil
}
ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))}
for name, g := range p.groups {
pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))}
for u, r := range g.members {
pg.Members[u] = r.String()
}
ps.Groups[name] = pg
}
data, err := json.MarshalIndent(ps, "", " ")
if err != nil {
return err
}
tmp := p.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, p.path)
}
// ── reads ──────────────────────────────────────────────────────────────────
// ResolveUser trims the proxy-provided header value and falls back to the
// configured default_user when it is empty (e.g. unproxied/dev deployments).
@@ -130,81 +342,335 @@ func (p *Policy) ResolveUser(headerValue string) string {
return u
}
// Level returns the global access level for a user. Users that are neither
// blacklisted nor anonymous get full write access.
// Level returns the coarse global access level for a user.
func (p *Policy) Level(user string) Level {
user = strings.TrimSpace(user)
if user == "" {
// No identity at all (no proxy header, no default_user): trusted LAN.
return LevelWrite
}
if lvl, ok := p.blacklist[user]; ok {
return lvl
}
return LevelWrite
p.mu.RLock()
defer p.mu.RUnlock()
return roleToLevel(p.effectiveRoleLocked(user))
}
// LogicRestricted reports whether a logic-editor allowlist is configured. When
// false, any write-capable user may edit panel/control logic.
func (p *Policy) LogicRestricted() bool {
return len(p.logicEditors) > 0
// EffectiveRole returns the highest role a user holds across all groups.
func (p *Policy) EffectiveRole(user string) Role {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user)
}
// CanEditLogic reports whether a user may add or edit panel logic and
// server-side control logic. When no allowlist is configured everyone with
// write access qualifies; otherwise the user (or one of their groups) must be
// listed. Anonymous/trusted-LAN callers (user=="") are always permitted.
// CanEditLogic reports whether a user may add or edit panel and control logic
// (logic-editor role or higher).
func (p *Policy) CanEditLogic(user string) bool {
user = strings.TrimSpace(user)
if user == "" {
return true
}
if !p.LogicRestricted() {
return true
}
if p.logicEditors[user] {
return true
}
for _, g := range p.userGroups[user] {
if p.logicEditors[g] {
return true
}
}
return false
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleLogic
}
// CanViewAudit reports whether a user may view the audit log. When no reader
// allowlist is configured everyone with read access qualifies; otherwise the
// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers
// (user=="") are always permitted.
// CanViewAudit reports whether a user may view the audit log (auditor or admin).
func (p *Policy) CanViewAudit(user string) bool {
user = strings.TrimSpace(user)
if user == "" {
return true
}
if len(p.auditReaders) == 0 {
return true
}
if p.auditReaders[user] {
return true
}
for _, g := range p.userGroups[user] {
if p.auditReaders[g] {
return true
}
}
return false
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleAuditor
}
// GroupsOf returns a copy of the groups a user belongs to.
func (p *Policy) GroupsOf(user string) []string {
src := p.userGroups[strings.TrimSpace(user)]
out := make([]string, len(src))
copy(out, src)
// CanAdmin reports whether a user may use the admin pane (admin role).
func (p *Policy) CanAdmin(user string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
return p.effectiveRoleLocked(user) >= RoleAdmin
}
// GroupNames returns a copy of every group name, sorted.
func (p *Policy) GroupNames() []string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.groupNamesLocked()
}
func (p *Policy) groupNamesLocked() []string {
out := make([]string, 0, len(p.groups))
for n := range p.groups {
out = append(out, n)
}
sort.Strings(out)
return out
}
// ── request-scoped user identity ────────────────────────────────────────────
// GroupsOf returns the groups a user effectively belongs to: every group they
// are an explicit member of, plus that group's descendants (membership flows
// parent→child). The implicit public-group viewer baseline is not included.
// Used by per-panel/folder sharing rules.
func (p *Policy) GroupsOf(user string) []string {
p.mu.RLock()
defer p.mu.RUnlock()
if user = strings.TrimSpace(user); user == "" {
return nil
}
set := make(map[string]bool)
for name, g := range p.groups {
if _, ok := g.members[user]; ok {
set[name] = true
p.addDescendantsLocked(name, set)
}
}
out := make([]string, 0, len(set))
for n := range set {
out = append(out, n)
}
sort.Strings(out)
return out
}
// addDescendantsLocked adds every transitive child of parent into set.
func (p *Policy) addDescendantsLocked(parent string, set map[string]bool) {
for name, g := range p.groups {
if g.parent == parent && !set[name] {
set[name] = true
p.addDescendantsLocked(name, set)
}
}
}
// ── admin snapshot ─────────────────────────────────────────────────────────
// MemberInfo is one user's role within a group.
type MemberInfo struct {
User string `json:"user"`
Role string `json:"role"`
}
// GroupInfo describes one group for the admin pane.
type GroupInfo struct {
Name string `json:"name"`
Parent string `json:"parent"`
Members []MemberInfo `json:"members"`
}
// UserInfo describes one user's memberships and resulting effective role.
type UserInfo struct {
Name string `json:"name"`
EffectiveRole string `json:"effectiveRole"`
Roles map[string]string `json:"roles"` // group → role token
}
// AccessSnapshot is the full mutable access state, rendered for the admin pane.
type AccessSnapshot struct {
DefaultUser string `json:"defaultUser"`
PublicGroup string `json:"publicGroup"`
Roles []string `json:"roles"` // role ladder, low→high
Configured bool `json:"configured"`
Users []UserInfo `json:"users"`
Groups []GroupInfo `json:"groups"`
}
// Snapshot returns a copy of the full access configuration for the admin pane.
func (p *Policy) Snapshot() AccessSnapshot {
p.mu.RLock()
defer p.mu.RUnlock()
snap := AccessSnapshot{
DefaultUser: p.defaultUser,
PublicGroup: PublicGroup,
Roles: append([]string(nil), RoleNames...),
Configured: p.configuredLocked(),
}
userSet := make(map[string]bool)
for _, name := range p.groupNamesLocked() {
g := p.groups[name]
gi := GroupInfo{Name: name, Parent: g.parent}
users := make([]string, 0, len(g.members))
for u := range g.members {
users = append(users, u)
userSet[u] = true
}
sort.Strings(users)
for _, u := range users {
gi.Members = append(gi.Members, MemberInfo{User: u, Role: g.members[u].String()})
}
snap.Groups = append(snap.Groups, gi)
}
users := make([]string, 0, len(userSet))
for u := range userSet {
users = append(users, u)
}
sort.Strings(users)
for _, u := range users {
ui := UserInfo{Name: u, EffectiveRole: p.effectiveRoleLocked(u).String(), Roles: make(map[string]string)}
for name, g := range p.groups {
if r, ok := g.members[u]; ok {
ui.Roles[name] = r.String()
}
}
snap.Users = append(snap.Users, ui)
}
return snap
}
// ── mutations (admin pane) ─────────────────────────────────────────────────
// ErrNotFound is returned when a named group does not exist.
var ErrNotFound = errors.New("group not found")
// SetMemberRole assigns a user a role within an existing group.
func (p *Policy) SetMemberRole(groupName, user string, role Role) error {
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
if groupName == "" {
return errors.New("empty group name")
}
if user == "" {
return errors.New("empty user")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[groupName]
if !ok {
return ErrNotFound
}
g.members[user] = role
return p.saveLocked()
}
// RemoveMember drops a user's explicit role in a group.
func (p *Policy) RemoveMember(groupName, user string) error {
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[groupName]
if !ok {
return ErrNotFound
}
delete(g.members, user)
return p.saveLocked()
}
// SetUserRoles replaces a user's full set of memberships: the user is placed in
// exactly the listed groups with the given roles and removed from all others.
// Listed groups that do not exist are created.
func (p *Policy) SetUserRoles(user string, roles map[string]Role) error {
user = strings.TrimSpace(user)
if user == "" {
return errors.New("empty user")
}
p.mu.Lock()
defer p.mu.Unlock()
want := make(map[string]Role, len(roles))
for g, r := range roles {
if g = strings.TrimSpace(g); g != "" {
want[g] = r
p.ensureGroupLocked(g)
}
}
for name, g := range p.groups {
if r, ok := want[name]; ok {
g.members[user] = r
} else {
delete(g.members, user)
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// CreateGroup adds an empty group with an optional parent if it does not exist.
func (p *Policy) CreateGroup(name, parent string) error {
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
if name == "" {
return errors.New("empty group name")
}
p.mu.Lock()
defer p.mu.Unlock()
if _, ok := p.groups[name]; ok {
return nil
}
g := p.ensureGroupLocked(name)
g.parent = parent
p.normalizeParentsLocked()
return p.saveLocked()
}
// SetGroup replaces an existing group's parent and members. The public group's
// parent is always forced to root.
func (p *Policy) SetGroup(name, parent string, members map[string]Role) error {
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
if name == "" {
return errors.New("empty group name")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[name]
if !ok {
return ErrNotFound
}
if name != PublicGroup {
g.parent = parent
}
g.members = make(map[string]Role, len(members))
for u, r := range members {
if u = strings.TrimSpace(u); u != "" {
g.members[u] = r
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// RenameGroup renames a group, re-pointing any children at the new name. The
// public group cannot be renamed.
func (p *Policy) RenameGroup(oldName, newName string) error {
oldName, newName = strings.TrimSpace(oldName), strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return errors.New("empty group name")
}
if oldName == PublicGroup {
return errors.New("cannot rename the public group")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[oldName]
if !ok {
return ErrNotFound
}
if oldName == newName {
return nil
}
if _, exists := p.groups[newName]; exists {
return errors.New("group already exists: " + newName)
}
delete(p.groups, oldName)
p.groups[newName] = g
for _, other := range p.groups {
if other.parent == oldName {
other.parent = newName
}
}
return p.saveLocked()
}
// DeleteGroup removes a group, reparenting its children to the deleted group's
// parent. The public group cannot be deleted; member users keep other groups.
func (p *Policy) DeleteGroup(name string) error {
name = strings.TrimSpace(name)
if name == PublicGroup {
return errors.New("cannot delete the public group")
}
p.mu.Lock()
defer p.mu.Unlock()
g, ok := p.groups[name]
if !ok {
return ErrNotFound
}
parent := g.parent
delete(p.groups, name)
for _, other := range p.groups {
if other.parent == name {
other.parent = parent
}
}
p.normalizeParentsLocked()
return p.saveLocked()
}
// ── request-scoped user identity ───────────────────────────────────────────
type ctxKey struct{}
+252 -21
View File
@@ -1,35 +1,266 @@
package access
import "testing"
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestCanEditLogic(t *testing.T) {
groups := map[string][]string{"ops": {"carol"}}
// spec is a small helper to build a GroupSpec.
func spec(name, parent string, members map[string]Role) GroupSpec {
return GroupSpec{Name: name, Parent: parent, Members: members}
}
// No allowlist configured: everyone with write access may edit logic.
open := New("", nil, groups, nil, nil)
func TestUnconfiguredIsOpen(t *testing.T) {
// No roles assigned anywhere → fully open (everyone is admin), matching a
// fresh/dev deployment.
p := New("", nil)
for _, u := range []string{"", "alice", "carol"} {
if !open.CanEditLogic(u) {
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
if !p.CanAdmin(u) {
t.Errorf("unconfigured: CanAdmin(%q) = false, want true", u)
}
if p.Level(u) != LevelWrite {
t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(u))
}
}
if open.LogicRestricted() {
t.Error("LogicRestricted() = true with no allowlist")
}
// Allowlist by username and by group name.
p := New("", nil, groups, []string{"alice", "ops"}, nil)
if !p.LogicRestricted() {
t.Error("LogicRestricted() = false with allowlist set")
func TestRoleLadder(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"adm": RoleAdmin}),
spec("ops", "", map[string]Role{
"viewer": RoleViewer,
"op": RoleOperator,
"logic": RoleLogic,
"aud": RoleAuditor,
}),
})
// Configured now → unlisted users (and anonymous) are viewers (read-only).
if p.Level("") != LevelRead {
t.Errorf("anonymous Level = %v, want read", p.Level(""))
}
cases := map[string]bool{
"": true, // anonymous / trusted LAN
"alice": true, // listed user
"carol": true, // member of listed group "ops"
"bob": false, // not listed
if p.Level("nobody") != LevelRead {
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
}
for u, want := range cases {
if got := p.CanEditLogic(u); got != want {
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
cases := []struct {
user string
level Level
editLogic bool
audit bool
admin bool
}{
{"viewer", LevelRead, false, false, false},
{"op", LevelWrite, false, false, false},
{"logic", LevelWrite, true, false, false},
{"aud", LevelWrite, true, true, false},
{"adm", LevelWrite, true, true, true},
}
for _, c := range cases {
if p.Level(c.user) != c.level {
t.Errorf("%s: Level = %v, want %v", c.user, p.Level(c.user), c.level)
}
if p.CanEditLogic(c.user) != c.editLogic {
t.Errorf("%s: CanEditLogic = %v, want %v", c.user, p.CanEditLogic(c.user), c.editLogic)
}
if p.CanViewAudit(c.user) != c.audit {
t.Errorf("%s: CanViewAudit = %v, want %v", c.user, p.CanViewAudit(c.user), c.audit)
}
if p.CanAdmin(c.user) != c.admin {
t.Errorf("%s: CanAdmin = %v, want %v", c.user, p.CanAdmin(c.user), c.admin)
}
}
}
func TestEffectiveRoleIsMaxAcrossGroups(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"x": RoleViewer}),
spec("a", "", map[string]Role{"x": RoleOperator}),
spec("b", "", map[string]Role{"x": RoleAuditor}),
})
if got := p.EffectiveRole("x"); got != RoleAuditor {
t.Errorf("EffectiveRole(x) = %v, want auditor (max across groups)", got)
}
}
func TestNestingInheritsMembership(t *testing.T) {
// org → team-a → squad-1. A member of org is effectively in the descendants
// too (membership flows parent→child), surfaced via GroupsOf for panel ACLs.
p := New("", []GroupSpec{
spec("org", "", map[string]Role{"boss": RoleAdmin}),
spec("team-a", "org", nil),
spec("squad-1", "team-a", nil),
})
groups := p.GroupsOf("boss")
for _, want := range []string{"org", "team-a", "squad-1"} {
if !containsStr(groups, want) {
t.Errorf("GroupsOf(boss) = %v, missing %q", groups, want)
}
}
}
func TestMutationsRoundTrip(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"root": RoleAdmin}),
spec("ops", "", map[string]Role{"carol": RoleOperator}),
})
// Set a user's full membership set: dave operator in ops, admin in eng (new).
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}); err != nil {
t.Fatal(err)
}
if g := p.GroupsOf("dave"); !containsStr(g, "ops") || !containsStr(g, "eng") {
t.Errorf("GroupsOf(dave) = %v, want ops+eng", g)
}
if !p.CanAdmin("dave") { // admin in eng
t.Error("CanAdmin(dave) = false after admin role in eng")
}
// Replacing membership removes dave from ops.
if err := p.SetUserRoles("dave", map[string]Role{"eng": RoleAdmin}); err != nil {
t.Fatal(err)
}
if containsStr(p.GroupsOf("dave"), "ops") {
t.Error("dave still in ops after membership replaced")
}
// Rename carries dave's admin grant from eng → engineering.
if err := p.RenameGroup("eng", "engineering"); err != nil {
t.Fatal(err)
}
if !p.CanAdmin("dave") {
t.Error("CanAdmin(dave) = false after eng renamed to engineering")
}
if !containsStr(p.GroupNames(), "engineering") || containsStr(p.GroupNames(), "eng") {
t.Errorf("GroupNames after rename = %v", p.GroupNames())
}
// Delete drops the group; dave loses admin but root (in public) keeps it.
if err := p.DeleteGroup("engineering"); err != nil {
t.Fatal(err)
}
if p.CanAdmin("dave") {
t.Error("CanAdmin(dave) = true after engineering deleted")
}
if !p.CanAdmin("root") {
t.Error("CanAdmin(root) = false; root grant in public should survive")
}
if err := p.DeleteGroup("nope"); err != ErrNotFound {
t.Errorf("DeleteGroup(nope) = %v, want ErrNotFound", err)
}
}
func TestPublicGroupProtected(t *testing.T) {
p := New("", []GroupSpec{spec("public", "", map[string]Role{"a": RoleAdmin})})
if err := p.DeleteGroup(PublicGroup); err == nil {
t.Error("DeleteGroup(public) = nil, want error")
}
if err := p.RenameGroup(PublicGroup, "other"); err == nil {
t.Error("RenameGroup(public) = nil, want error")
}
if !containsStr(p.GroupNames(), PublicGroup) {
t.Error("public group missing after protected mutations")
}
}
func TestParentCycleRejected(t *testing.T) {
p := New("", []GroupSpec{
spec("a", "", map[string]Role{"x": RoleViewer}),
spec("b", "a", nil),
})
// Making a's parent b would create a cycle a→b→a; it must be dropped to root.
if err := p.SetGroup("a", "b", map[string]Role{"x": RoleViewer}); err != nil {
t.Fatal(err)
}
snap := p.Snapshot()
for _, g := range snap.Groups {
if g.Name == "a" && g.Parent != "" {
t.Errorf("group a parent = %q, want root (cycle should be dropped)", g.Parent)
}
}
}
func TestPersistenceRoundTrip(t *testing.T) {
dir := t.TempDir()
p := New("admin", []GroupSpec{
spec("public", "", map[string]Role{"alice": RoleLogic}),
spec("ops", "", map[string]Role{"carol": RoleAdmin}),
})
if err := p.EnablePersistence(dir); err != nil {
t.Fatal(err)
}
// A mutation triggers the first write of access.json.
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "access.json")); err != nil {
t.Fatalf("access.json not written: %v", err)
}
// A fresh policy loading the same dir should see the persisted state, not the
// (different) seed it was constructed with.
p2 := New("seed", []GroupSpec{spec("public", "", map[string]Role{"seeduser": RoleViewer})})
if err := p2.EnablePersistence(dir); err != nil {
t.Fatal(err)
}
if p2.ResolveUser("") != "admin" {
t.Errorf("default user = %q, want admin", p2.ResolveUser(""))
}
if !p2.CanEditLogic("alice") || p2.CanEditLogic("zed") {
t.Error("logic-editor role not persisted")
}
if !p2.CanAdmin("carol") {
t.Error("admin role not persisted")
}
if !containsStr(p2.GroupsOf("dave"), "ops") {
t.Error("dave membership not persisted")
}
if p2.EffectiveRole("seeduser") != RoleViewer || p2.Level("seeduser") != LevelRead {
// seeduser came from the discarded seed; should be an unlisted viewer now.
t.Error("persisted state did not supersede the seed")
}
}
func TestConcurrentAccess(t *testing.T) {
p := New("", []GroupSpec{
spec("public", "", map[string]Role{"root": RoleAdmin}),
spec("ops", "", map[string]Role{"carol": RoleOperator}),
})
if err := p.EnablePersistence(t.TempDir()); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(2)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
p.CanAdmin("carol")
p.Level("carol")
p.GroupsOf("carol")
p.Snapshot()
}
}()
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
p.SetMemberRole("ops", "carol", RoleAuditor)
p.SetUserRoles("carol", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin})
p.RemoveMember("ops", "carol")
}
}()
}
wg.Wait()
}
func containsStr(xs []string, v string) bool {
for _, x := range xs {
if x == v {
return true
}
}
return false
}
+55
View File
@@ -0,0 +1,55 @@
package access
import (
"slices"
"strings"
)
// Visibility scope tokens shared by user-owned, filterable objects across uopi
// (config sets/instances, control-logic graphs, synthetic signals). They drive
// the per-tree "Mine / Group / Global" selector. Storage carries the scope as a
// plain string so each subsystem can embed it in its own JSON without depending
// on this package's types.
const (
// ScopePrivate: visible only to the owner.
ScopePrivate = "private"
// ScopeGroup: visible to the owner and members of any listed group.
ScopeGroup = "group"
// ScopeGlobal: visible to everyone. This is also the legacy default — an
// empty/unknown scope is treated as global so objects created before scopes
// existed stay visible to all.
ScopeGlobal = "global"
)
// CanSee reports whether user (a member of userGroups) may see an object with the
// given owner, scope and itemGroups. An empty or unrecognised scope is treated as
// global, so legacy objects without a scope remain visible to everyone.
//
// This is a visibility filter for selector trees, not a hard security boundary:
// it governs which objects are offered in listings, and intentionally always
// shows an object to its owner regardless of scope.
func CanSee(user, owner, scope string, itemGroups, userGroups []string) bool {
switch strings.ToLower(strings.TrimSpace(scope)) {
case ScopePrivate:
return owner != "" && owner == user
case ScopeGroup:
if owner != "" && owner == user {
return true
}
for _, g := range itemGroups {
if slices.Contains(userGroups, g) {
return true
}
}
return false
default: // global / empty / unknown
return true
}
}
// CanSee reports whether user may see an object with the given owner, scope and
// itemGroups, resolving the user's group memberships from the policy. It is the
// convenience wrapper list handlers use.
func (p *Policy) CanSee(user, owner, scope string, itemGroups []string) bool {
return CanSee(user, owner, scope, itemGroups, p.GroupsOf(user))
}
+47
View File
@@ -0,0 +1,47 @@
package access
import "testing"
func TestCanSee(t *testing.T) {
cases := []struct {
name string
user string
owner string
scope string
itemGroups []string
userGroups []string
want bool
}{
{"global visible to anyone", "bob", "alice", ScopeGlobal, nil, nil, true},
{"empty scope = global", "bob", "alice", "", nil, nil, true},
{"unknown scope = global", "bob", "alice", "weird", nil, nil, true},
{"private hidden from others", "bob", "alice", ScopePrivate, nil, nil, false},
{"private visible to owner", "alice", "alice", ScopePrivate, nil, nil, true},
{"private with empty owner hidden", "bob", "", ScopePrivate, nil, nil, false},
{"group visible to member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"ops"}, true},
{"group hidden from non-member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"eng"}, false},
{"group visible to owner even if not a member", "alice", "alice", ScopeGroup, []string{"ops"}, nil, true},
{"group with multiple item groups", "bob", "alice", ScopeGroup, []string{"ops", "eng"}, []string{"eng"}, true},
{"case-insensitive scope token", "bob", "alice", "PRIVATE", nil, nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := CanSee(c.user, c.owner, c.scope, c.itemGroups, c.userGroups); got != c.want {
t.Errorf("CanSee(%q,%q,%q,%v,%v) = %v, want %v",
c.user, c.owner, c.scope, c.itemGroups, c.userGroups, got, c.want)
}
})
}
}
func TestPolicyCanSeeResolvesGroups(t *testing.T) {
p := New("", []GroupSpec{{Name: "ops", Members: map[string]Role{"bob": RoleOperator}}})
// bob is a member of ops; a group-scoped item shared with ops is visible.
if !p.CanSee("bob", "alice", ScopeGroup, []string{"ops"}) {
t.Errorf("expected bob to see an ops-scoped item")
}
// carol is in no group; the same item is hidden.
if p.CanSee("carol", "alice", ScopeGroup, []string{"ops"}) {
t.Errorf("expected carol not to see an ops-scoped item")
}
}
+312 -18
View File
@@ -2,6 +2,7 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@@ -12,6 +13,8 @@ import (
"net"
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"time"
@@ -23,6 +26,7 @@ import (
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/metrics"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
@@ -40,12 +44,13 @@ type Handler struct {
audit audit.Recorder // never nil; audit.Nop when disabled
channelFinderURL string // empty if not configured
archiverURL string // empty if not configured
uiDefaultZoom float64 // base UI scale sent to clients; 0 means 1.0
log *slog.Logger
}
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, uiDefaultZoom float64, log *slog.Logger) *Handler {
if rec == nil {
rec = audit.Nop()
}
@@ -61,6 +66,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg
audit: rec,
channelFinderURL: channelFinderURL,
archiverURL: archiverURL,
uiDefaultZoom: uiDefaultZoom,
log: log,
}
}
@@ -97,6 +103,14 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
// User groups defined in config (read-only; for the sharing UI)
mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups)
// Admin pane: runtime access management + server statistics. Every route is
// gated by CanAdmin (the admins allowlist).
mux.HandleFunc("GET "+prefix+"/admin/access", h.getAdminAccess)
mux.HandleFunc("PUT "+prefix+"/admin/users/{user}", h.putAdminUser)
mux.HandleFunc("POST "+prefix+"/admin/groups", h.createAdminGroup)
mux.HandleFunc("PUT "+prefix+"/admin/groups/{name}", h.updateAdminGroup)
mux.HandleFunc("DELETE "+prefix+"/admin/groups/{name}", h.deleteAdminGroup)
mux.HandleFunc("GET "+prefix+"/admin/stats", h.getAdminStats)
// Signal group tree
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
@@ -106,6 +120,8 @@ 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)
// Single-shot debug trace of an unsaved synthetic graph (live editor overlay).
mux.HandleFunc("POST "+prefix+"/synthetic/trace", h.traceSynthetic)
// 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)
@@ -156,6 +172,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
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("POST "+prefix+"/config/rules/preview", h.previewConfigRule)
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)
@@ -177,12 +194,18 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
if groups == nil {
groups = []string{}
}
defaultZoom := h.uiDefaultZoom
if defaultZoom <= 0 {
defaultZoom = 1.0
}
jsonOK(w, map[string]any{
"user": user,
"level": h.policy.Level(user).String(),
"groups": groups,
"canEditLogic": h.policy.CanEditLogic(user),
"canViewAudit": h.policy.CanViewAudit(user),
"canAdmin": h.policy.CanAdmin(user),
"defaultZoom": defaultZoom,
})
}
@@ -284,23 +307,25 @@ func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
return p
}
// panelPerm returns the caller's effective permission on a panel. A request
// with no resolved identity (no proxy header and no default_user) is a trusted
// LAN deployment with no per-user enforcement, so it gets full write.
// panelPerm returns the caller's effective permission on a panel. A request with
// no resolved identity (no proxy header and no default_user) has no per-user ACL,
// so it is governed purely by the global level: full write on an unconfigured
// (open) policy, read-only once roles are configured.
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
user := caller(r)
if user == "" {
return panelacl.PermWrite
return h.capByGlobal("", panelacl.PermWrite)
}
return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user)))
}
// folderPerm returns the caller's effective permission on a folder. As with
// panelPerm, an unidentified caller is trusted with full write.
// panelPerm, an unidentified caller is governed by the global level (full write
// on an unconfigured/open policy, read-only once roles are configured).
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
user := caller(r)
if user == "" {
return panelacl.PermWrite
return h.capByGlobal("", panelacl.PermWrite)
}
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
}
@@ -348,8 +373,9 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
if dsName == "synthetic" && h.synthetic != nil {
user := caller(r)
panel := r.URL.Query().Get("panel")
userGroups := h.policy.GroupsOf(user)
metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
return synVisible(d, user, panel)
return synVisible(d, user, panel, userGroups)
})
out := make([]signalInfo, len(metas))
for i, m := range metas {
@@ -380,12 +406,15 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
}
// synVisible reports whether a synthetic signal should be listed for the given
// caller while editing the given panel. An empty Visibility is treated as
// "global" so legacy definitions remain visible everywhere.
func synVisible(d synthetic.SignalDef, user, panel string) bool {
// caller (a member of userGroups) while editing the given panel. An empty
// Visibility is treated as "global" so legacy definitions remain visible
// everywhere.
func synVisible(d synthetic.SignalDef, user, panel string, userGroups []string) bool {
switch d.Visibility {
case "user":
return user != "" && d.Owner == user
case "group":
return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups)
case "panel":
return panel != "" && d.Panel == panel
default: // "global" or legacy empty
@@ -413,13 +442,14 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
user := caller(r)
panel := r.URL.Query().Get("panel")
userGroups := h.policy.GroupsOf(user)
var out []signalInfo
for _, ds := range sources {
var metas []datasource.Metadata
if ds.Name() == "synthetic" && h.synthetic != nil {
metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
return synVisible(d, user, panel)
return synVisible(d, user, panel, userGroups)
})
} else {
var err error
@@ -579,6 +609,29 @@ type interfaceListItem struct {
Folder string `json:"folder,omitempty"`
Order float64 `json:"order,omitempty"`
Perm string `json:"perm"`
Scope string `json:"scope,omitempty"` // derived visibility bucket: private|group|global
Groups []string `json:"groups,omitempty"` // groups a group-scoped panel is shared with
}
// panelScope derives a uniform visibility token (and, for group scope, the
// shared group names) from a panel's ACL record so the selector tree can bucket
// it as Mine/Group/Global like the other scoped subsystems. An unmanaged panel
// (nil record) or any panel exposed publicly is global; otherwise a panel shared
// with one or more user-groups is group-scoped; everything else is private.
func panelScope(acl *panelacl.PanelACL) (string, []string) {
if acl == nil || acl.Public != "" {
return access.ScopeGlobal, nil
}
var groups []string
for _, g := range acl.Grants {
if g.Kind == "group" {
groups = append(groups, g.Name)
}
}
if len(groups) > 0 {
return access.ScopeGroup, groups
}
return access.ScopePrivate, nil
}
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
@@ -595,11 +648,13 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
continue // hide panels the caller cannot see
}
item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
if acl := h.acl.GetPanel(m.ID); acl != nil {
acl := h.acl.GetPanel(m.ID)
if acl != nil {
item.Owner = acl.Owner
item.Folder = acl.Folder
item.Order = acl.Order
}
item.Scope, item.Groups = panelScope(acl)
out = append(out, item)
}
h.log.Info("list interfaces", "count", len(out))
@@ -1114,6 +1169,209 @@ func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, names)
}
// ── /admin ──────────────────────────────────────────────────────────────────
// requireAdmin writes a 403 and returns false unless the caller may use the
// admin pane (CanAdmin). Anonymous/trusted-LAN callers and, when no admins
// allowlist is configured, everyone, are permitted.
func (h *Handler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
if h.policy.CanAdmin(caller(r)) {
return true
}
jsonError(w, http.StatusForbidden, "you are not permitted to administer this server")
return false
}
// getAdminAccess returns the full mutable access configuration (users, groups,
// allowlists) for the admin pane.
func (h *Handler) getAdminAccess(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
jsonOK(w, h.policy.Snapshot())
}
type adminUserReq struct {
// Roles maps each group the user should belong to → their role token. The
// user is removed from any group not listed. Unknown groups are created.
Roles map[string]string `json:"roles"`
}
// putAdminUser replaces a user's full set of (group → role) memberships.
func (h *Handler) putAdminUser(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
user := strings.TrimSpace(r.PathValue("user"))
if user == "" {
jsonError(w, http.StatusBadRequest, "empty user")
return
}
var req adminUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
roles := make(map[string]access.Role, len(req.Roles))
for g, token := range req.Roles {
if g = strings.TrimSpace(g); g != "" {
roles[g] = access.ParseRole(token)
}
}
if err := h.policy.SetUserRoles(user, roles); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
h.recordMutation(r, "admin.user", user)
jsonOK(w, h.policy.Snapshot())
}
type adminGroupReq struct {
Name string `json:"name"` // for update: the (possibly new) group name
Parent string `json:"parent"` // parent group for nesting ("" = root)
Members map[string]string `json:"members"`
}
// createAdminGroup creates an empty group with an optional parent.
func (h *Handler) createAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
var req adminGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if strings.TrimSpace(req.Name) == "" {
jsonError(w, http.StatusBadRequest, "empty group name")
return
}
if err := h.policy.CreateGroup(req.Name, req.Parent); err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.create", req.Name)
w.WriteHeader(http.StatusCreated)
jsonOK(w, h.policy.Snapshot())
}
// updateAdminGroup renames a group (when the body name differs from the path)
// and replaces its parent and member roles.
func (h *Handler) updateAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
old := strings.TrimSpace(r.PathValue("name"))
var req adminGroupReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
name = old
}
if name != old {
if err := h.policy.RenameGroup(old, name); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+old)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
}
members := make(map[string]access.Role, len(req.Members))
for u, token := range req.Members {
if u = strings.TrimSpace(u); u != "" {
members[u] = access.ParseRole(token)
}
}
if err := h.policy.SetGroup(name, req.Parent, members); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+name)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.update", name)
jsonOK(w, h.policy.Snapshot())
}
// deleteAdminGroup removes a group (members keep their other groups; children
// are reparented). The built-in public group cannot be deleted.
func (h *Handler) deleteAdminGroup(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
name := strings.TrimSpace(r.PathValue("name"))
if err := h.policy.DeleteGroup(name); err != nil {
if errors.Is(err, access.ErrNotFound) {
jsonError(w, http.StatusNotFound, "group not found: "+name)
return
}
jsonError(w, http.StatusBadRequest, err.Error())
return
}
h.recordMutation(r, "admin.group.delete", name)
jsonOK(w, h.policy.Snapshot())
}
// getAdminStats reports live server statistics: the in-process metrics counters,
// observed signals and data sources from the broker, Go runtime stats, and the
// system load average on Linux.
func (h *Handler) getAdminStats(w http.ResponseWriter, r *http.Request) {
if !h.requireAdmin(w, r) {
return
}
m := metrics.Snapshot()
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
sources := h.broker.DataSources()
dsNames := make([]string, len(sources))
for i, ds := range sources {
dsNames[i] = ds.Name()
}
jsonOK(w, map[string]any{
"uptimeSeconds": m.UptimeSeconds,
"wsConnections": m.WsConnections,
"observedSignals": h.broker.ActiveSubscriptions(),
"msgIn": m.MsgIn,
"msgOut": m.MsgOut,
"writeOps": m.WriteOps,
"historyReqs": m.HistoryReqs,
"goroutines": runtime.NumGoroutine(),
"memAllocBytes": ms.Alloc,
"memSysBytes": ms.Sys,
"heapInuseBytes": ms.HeapInuse,
"loadAvg": readLoadAvg(),
"dataSources": dsNames,
})
}
// readLoadAvg returns the 1/5/15-minute system load averages from
// /proc/loadavg, or nil on non-Linux systems or any read/parse error.
func readLoadAvg() []float64 {
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return nil
}
fields := strings.Fields(string(data))
if len(fields) < 3 {
return nil
}
out := make([]float64, 3)
for i := 0; i < 3; i++ {
v, err := strconv.ParseFloat(fields[i], 64)
if err != nil {
return nil
}
out[i] = v
}
return out
}
// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
func genID(prefix string) string {
var b [8]byte
@@ -1221,6 +1479,36 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// traceSynthetic evaluates an unsaved synthetic graph once against the current
// live value of each source signal and returns every node's computed value. It
// persists nothing; it powers the editor's live/debug overlay.
func (h *Handler) traceSynthetic(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
return
}
var def synthetic.SignalDef
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
read := func(ds, name string) (any, error) {
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: name})
if err != nil {
return nil, err
}
return v.Data, nil
}
res, err := h.synthetic.Trace(def, read)
if err != nil {
jsonError(w, http.StatusBadRequest, err.Error())
return
}
jsonOK(w, res)
}
func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
if h.synthetic == nil {
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
@@ -1297,14 +1585,17 @@ func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
// ── Control logic ──────────────────────────────────────────────────────────────
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) listControlLogic(w http.ResponseWriter, r *http.Request) {
if h.ctrlLogic == nil {
jsonOK(w, []any{})
return
}
graphs := h.ctrlLogic.List()
if graphs == nil {
graphs = []controllogic.Graph{}
user := caller(r)
graphs := []controllogic.Graph{}
for _, g := range h.ctrlLogic.List() {
if h.policy.CanSee(user, g.Owner, g.Scope, g.ScopeGroups) {
graphs = append(graphs, g)
}
}
jsonOK(w, graphs)
}
@@ -1337,6 +1628,7 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
g.ID = genID("cl")
g.Owner = caller(r) // stamp ownership from the trusted identity
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
@@ -1357,7 +1649,8 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
id := r.PathValue("id")
if _, err := h.ctrlLogic.Get(id); err != nil {
prev, err := h.ctrlLogic.Get(id)
if err != nil {
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
return
}
@@ -1367,6 +1660,7 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
return
}
g.ID = id
g.Owner = prev.Owner // owner is immutable across revisions
if err := h.ctrlLogic.Save(g); err != nil {
jsonError(w, http.StatusInternalServerError, err.Error())
return
+147 -1
View File
@@ -61,7 +61,7 @@ func setup(t *testing.T) (*httptest.Server, func()) {
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")
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() {
@@ -452,6 +452,152 @@ func TestStorageValidateID(t *testing.T) {
}
}
// ── /api/v1/admin ─────────────────────────────────────────────────────────────
// adminSetup builds a server whose mux injects the user named by the
// "X-Test-User" header into the request context (mirroring the real access
// middleware), so admin-gating can be exercised. The policy restricts admin to
// the "ops" group, of which "alice" is a member.
func adminSetup(t *testing.T) (*httptest.Server, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.New(slog.NewTextHandler(io.Discard, nil))
brk := broker.New(ctx, log)
ds := stub.New()
if err := ds.Connect(ctx); err != nil {
t.Fatal("stub connect:", err)
}
brk.Register(ds)
dir := t.TempDir()
store, _ := storage.New(dir)
acl, _ := panelacl.New(dir)
clStore, _ := controllogic.NewStore(dir)
cfgStore, _ := confmgr.New(dir)
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
// "alice" is an admin (via the ops group); everyone else is a viewer.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}},
})
if err := policy.EnablePersistence(dir); err != nil {
t.Fatal("EnablePersistence:", err)
}
inner := http.NewServeMux()
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(inner, "/api/v1")
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if u := r.Header.Get("X-Test-User"); u != "" {
r = r.WithContext(access.WithUser(r.Context(), u))
}
inner.ServeHTTP(w, r)
})
srv := httptest.NewServer(mux)
return srv, func() { srv.Close(); cancel() }
}
func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response {
t.Helper()
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, srv.URL+path, rdr)
if err != nil {
t.Fatal("NewRequest:", err)
}
if user != "" {
req.Header.Set("X-Test-User", user)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(method, path, err)
}
return resp
}
func TestAdminForbiddenForNonAdmin(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
// "bob" is only a viewer → 403 on every admin route.
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden)
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden)
assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob",
[]byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden)
}
func TestAdminUserAndGroupMutations(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
// alice (admin) assigns carol an auditor role in a new "team" group.
resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice",
[]byte(`{"roles":{"team":"auditor"}}`))
assertStatus(t, resp, http.StatusOK)
var snap access.AccessSnapshot
readJSON(t, resp, &snap)
var carol *access.UserInfo
for i := range snap.Users {
if snap.Users[i].Name == "carol" {
carol = &snap.Users[i]
}
}
if carol == nil {
t.Fatal("carol missing from snapshot")
}
if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" {
t.Errorf("carol = %+v, want auditor in team", carol)
}
// Create (with parent), rename, and delete a group.
assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice",
[]byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated)
resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice",
[]byte(`{"name":"engineering","members":{"carol":"admin"}}`))
assertStatus(t, resp, http.StatusOK)
readJSON(t, resp, &snap)
if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") {
t.Errorf("groups after rename = %+v", snap.Groups)
}
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK)
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound)
// The built-in public group cannot be deleted.
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest)
}
func hasGroup(s access.AccessSnapshot, name string) bool {
for _, g := range s.Groups {
if g.Name == name {
return true
}
}
return false
}
func TestAdminStats(t *testing.T) {
srv, teardown := adminSetup(t)
defer teardown()
resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil)
assertStatus(t, resp, http.StatusOK)
var stats map[string]any
readJSON(t, resp, &stats)
for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} {
if _, ok := stats[k]; !ok {
t.Errorf("stats missing key %q", k)
}
}
}
// ── Ensure os is used (blank import guard) ─────────────────────────────────────
var _ = os.DevNull
+57 -4
View File
@@ -37,7 +37,7 @@ func configStatus(err error) int {
// ── config sets ─────────────────────────────────────────────────────────────
func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
@@ -46,7 +46,7 @@ func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, sets)
jsonOK(w, h.filterConfigMetas(r, sets))
}
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
@@ -92,6 +92,9 @@ func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if prev, err := h.cfg.GetSet(id); err == nil {
set.Owner = prev.Owner // owner is immutable across revisions
}
out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
@@ -227,7 +230,7 @@ func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
// ── config instances ────────────────────────────────────────────────────────
func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
@@ -236,7 +239,20 @@ func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
jsonError(w, http.StatusInternalServerError, err.Error())
return
}
jsonOK(w, insts)
jsonOK(w, h.filterConfigMetas(r, insts))
}
// filterConfigMetas drops entries the caller may not see per their scope
// (private/group/global). Owner always sees their own; empty scope = global.
func (h *Handler) filterConfigMetas(r *http.Request, metas []confmgr.Meta) []confmgr.Meta {
user := caller(r)
out := make([]confmgr.Meta, 0, len(metas))
for _, m := range metas {
if h.policy.CanSee(user, m.Owner, m.Scope, m.Groups) {
out = append(out, m)
}
}
return out
}
func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) {
@@ -282,6 +298,9 @@ func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if prev, err := h.cfg.GetInstance(id); err == nil {
inst.Owner = prev.Owner // owner is immutable across revisions
}
out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag"))
if err != nil {
jsonError(w, configStatus(err), err.Error())
@@ -765,3 +784,37 @@ func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) {
}
jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values))
}
// previewConfigRule evaluates a (possibly unsaved) CUE source against a live
// snapshot of the bound set's target signals, without storing anything. Unlike
// check (which uses sample/default values), preview reads the current hardware
// values so the operator sees exactly what the rule would derive from the real
// configuration. Body: {setId, source}. Returns the captured snapshot plus the
// rule result (violations + transformed values).
func (h *Handler) previewConfigRule(w http.ResponseWriter, r *http.Request) {
if !h.configEnabled(w) {
return
}
var body struct {
SetID string `json:"setId"`
Source string `json:"source"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
set, err := h.cfg.GetSet(body.SetID)
if err != nil {
jsonError(w, configStatus(err), 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))
res := confmgr.EvaluateRule(body.Source, snap.Values)
jsonOK(w, struct {
Snapshot confmgr.SnapshotResult `json:"snapshot"`
Result confmgr.RuleResult `json:"result"`
}{snap, res})
}
+146
View File
@@ -0,0 +1,146 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigRuleCRUD exercises the full config-rule REST surface: list, create,
// get, update, versioning (list/get/promote/fork), check, preview, and delete,
// plus the principal error paths.
func TestConfigRuleCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// A set the rule (and preview) can bind to.
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "S",
"parameters": []map[string]any{
{"key": "voltage", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 12.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
// Empty list initially.
resp = get(t, srv, "/api/v1/config/rules")
assertStatus(t, resp, http.StatusOK)
var list []map[string]any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no rules, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
"name": "cap",
"setId": set.ID,
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusCreated)
var rule struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &rule)
if rule.ID == "" {
t.Fatal("rule missing id")
}
// Create with invalid CUE → 400.
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
"name": "bad",
"setId": set.ID,
"source": "voltage: <=",
})
assertStatus(t, resp, http.StatusBadRequest)
// Create with malformed JSON → 400.
resp = postRaw(t, srv, "/api/v1/config/rules", "application/json", []byte(`{not json`))
assertStatus(t, resp, http.StatusBadRequest)
// Get.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/config/rules/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (bumps version, creates backup).
resp = putRaw(t, srv, "/api/v1/config/rules/"+rule.ID, "application/json",
[]byte(`{"name":"cap2","setId":"`+set.ID+`","source":"voltage: <=30"}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 rule versions, got %d", len(versions))
}
// Get v1.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version path → 400.
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
// Promote v1.
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new id.
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == rule.ID {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Check (live validation of an unsaved source against sample values).
resp = postJSON(t, srv, "/api/v1/config/rules/check", map[string]any{
"source": "voltage: <=24",
"values": map[string]any{"voltage": 20.0},
})
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Check with malformed JSON → 400.
resp = postRaw(t, srv, "/api/v1/config/rules/check", "application/json", []byte(`{`))
assertStatus(t, resp, http.StatusBadRequest)
// Preview against the bound set's live signals.
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
"setId": set.ID,
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Preview with an unknown set → 404.
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
"setId": "does-not-exist",
"source": "voltage: <=24",
})
assertStatus(t, resp, http.StatusNotFound)
// Delete.
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
assertStatus(t, resp, http.StatusNotFound)
}
+158
View File
@@ -0,0 +1,158 @@
package api_test
import (
"net/http"
"testing"
)
// TestConfigSetVersioning exercises the set update/version/promote/fork/diff and
// snapshot endpoints that the base end-to-end test does not reach.
func TestConfigSetVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
mk := func(name string) string {
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": name,
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var s struct {
ID string `json:"id"`
}
readJSON(t, resp, &s)
return s.ID
}
id := mk("Set A")
// Update → version bump + backup.
resp := putRaw(t, srv, "/api/v1/config/sets/"+id, "application/json",
[]byte(`{"name":"Set A v2","parameters":[{"key":"sp","ds":"stub","signal":"setpoint","type":"float64","default":2.0}]}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 set versions, got %d", len(versions))
}
// Get a specific version.
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Promote v1.
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new set id.
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == id {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Diff the original against the fork.
resp = get(t, srv, "/api/v1/config/sets/diff?a="+id+"&b="+fork.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Diff with a missing left id → 400.
resp = get(t, srv, "/api/v1/config/sets/diff?a=&b="+fork.ID)
assertStatus(t, resp, http.StatusBadRequest)
// Snapshot the set into a new instance.
resp = postJSON(t, srv, "/api/v1/config/sets/"+id+"/snapshot", map[string]any{"name": "snap-1"})
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
}
// TestConfigInstanceVersioning exercises the instance update/version/promote/
// fork/validate and live-diff endpoints.
func TestConfigInstanceVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// A set to bind instances to.
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
"name": "Set B",
"parameters": []map[string]any{
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
},
})
assertStatus(t, resp, http.StatusCreated)
var set struct {
ID string `json:"id"`
}
readJSON(t, resp, &set)
// Create an instance.
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
"name": "inst", "setId": set.ID, "values": map[string]any{"sp": 5.0},
})
assertStatus(t, resp, http.StatusCreated)
var inst struct {
ID string `json:"id"`
}
readJSON(t, resp, &inst)
// Get it.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// List instances.
resp = get(t, srv, "/api/v1/config/instances")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Update → version bump.
resp = putRaw(t, srv, "/api/v1/config/instances/"+inst.ID, "application/json",
[]byte(`{"name":"inst v2","setId":"`+set.ID+`","values":{"sp":7.0}}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 instance versions, got %d", len(versions))
}
// Get a version.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Promote + fork.
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Validate against the (empty) rule set → 200.
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/validate", nil)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Live diff against current signal values → 200.
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/livediff")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Delete the instance.
resp = deleteReq(t, srv, "/api/v1/config/instances/"+inst.ID)
assertStatus(t, resp, http.StatusNoContent)
}
+438
View File
@@ -0,0 +1,438 @@
package api_test
import (
"encoding/json"
"io"
"net/http"
"testing"
)
// These tests exercise the many handlers reachable through the default setup()
// harness, whose policy is unconfigured (anonymous caller → write/admin), so
// permission gates default to "allow" and the focus is handler behaviour.
// ── /me ───────────────────────────────────────────────────────────────────────
func TestGetMe(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/me")
assertStatus(t, resp, http.StatusOK)
var me map[string]any
readJSON(t, resp, &me)
for _, k := range []string{"user", "level", "groups", "canEditLogic", "canViewAudit", "canAdmin", "defaultZoom"} {
if _, ok := me[k]; !ok {
t.Errorf("/me missing key %q", k)
}
}
// Unconfigured policy → anonymous caller has write level.
if me["level"] != "write" {
t.Errorf("level = %v, want write", me["level"])
}
}
// ── /groups (signal group tree) ───────────────────────────────────────────────
func TestGroupsRoundTrip(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Initially valid JSON.
resp := get(t, srv, "/api/v1/groups")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Replace with a JSON array → 204.
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`[{"name":"A"},{"name":"B"}]`))
assertStatus(t, resp, http.StatusNoContent)
// Read back what we wrote.
resp = get(t, srv, "/api/v1/groups")
assertStatus(t, resp, http.StatusOK)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var tree []map[string]any
if err := json.Unmarshal(body, &tree); err != nil {
t.Fatalf("groups body not a JSON array: %v (%s)", err, body)
}
if len(tree) != 2 {
t.Fatalf("expected 2 groups, got %d", len(tree))
}
// A non-array body is rejected.
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`{"not":"array"}`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /usergroups ───────────────────────────────────────────────────────────────
func TestListUserGroups(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/usergroups")
assertStatus(t, resp, http.StatusOK)
var names []string
readJSON(t, resp, &names)
// Unconfigured policy has no named groups; the handler must still return [].
if names == nil {
t.Error("expected non-nil (possibly empty) group list")
}
}
// ── /folders ──────────────────────────────────────────────────────────────────
func TestFolderCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// List — initially empty.
resp := get(t, srv, "/api/v1/folders")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no folders, got %d", len(list))
}
// Missing name → 400.
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": ""})
assertStatus(t, resp, http.StatusBadRequest)
// Create.
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": "Reactor"})
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
if created.ID == "" {
t.Fatal("expected folder id")
}
// Update.
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json",
[]byte(`{"name":"Reactor Hall"}`))
assertStatus(t, resp, http.StatusNoContent)
// Update with empty name → 400.
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json", []byte(`{"name":""}`))
assertStatus(t, resp, http.StatusBadRequest)
// Update unknown folder → 404.
resp = putRaw(t, srv, "/api/v1/folders/fld-nope", "application/json", []byte(`{"name":"x"}`))
assertStatus(t, resp, http.StatusNotFound)
// It now appears in the list.
resp = get(t, srv, "/api/v1/folders")
assertStatus(t, resp, http.StatusOK)
readJSON(t, resp, &list)
if len(list) != 1 {
t.Fatalf("expected 1 folder, got %d", len(list))
}
// Delete.
resp = deleteReq(t, srv, "/api/v1/folders/"+created.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete unknown → 404.
resp = deleteReq(t, srv, "/api/v1/folders/fld-nope")
assertStatus(t, resp, http.StatusNotFound)
}
// ── /interfaces/{id}/acl ──────────────────────────────────────────────────────
func TestPanelACL(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
// GET ACL of a fresh panel.
resp = get(t, srv, "/api/v1/interfaces/"+created.ID+"/acl")
assertStatus(t, resp, http.StatusOK)
var acl map[string]any
readJSON(t, resp, &acl)
if _, ok := acl["perm"]; !ok {
t.Error("ACL response missing perm")
}
// PUT ACL — set a public read level.
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
[]byte(`{"public":"read","grants":[]}`))
assertStatus(t, resp, http.StatusNoContent)
// PUT ACL on a non-existent panel → 404.
resp = putRaw(t, srv, "/api/v1/interfaces/nope/acl", "application/json",
[]byte(`{"public":"read"}`))
assertStatus(t, resp, http.StatusNotFound)
// PUT ACL referencing an unknown folder → 400.
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
[]byte(`{"folder":"fld-nope"}`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /interfaces/reorder ───────────────────────────────────────────────────────
func TestReorderInterfaces(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
mk := func() string {
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var c struct {
ID string `json:"id"`
}
readJSON(t, resp, &c)
return c.ID
}
a, b := mk(), mk()
// Reorder at root (no folder).
resp := postJSON(t, srv, "/api/v1/interfaces/reorder", map[string]any{"ids": []string{b, a}})
assertStatus(t, resp, http.StatusNoContent)
// Reorder into a non-existent folder → 404.
resp = postJSON(t, srv, "/api/v1/interfaces/reorder",
map[string]any{"folder": "fld-nope", "ids": []string{a}})
assertStatus(t, resp, http.StatusNotFound)
// Malformed body → 400.
resp = postRaw(t, srv, "/api/v1/interfaces/reorder", "application/json", []byte(`{not json`))
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /interfaces/{id}/versions ─────────────────────────────────────────────────
func TestInterfaceVersioning(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
assertStatus(t, resp, http.StatusCreated)
var created struct {
ID string `json:"id"`
}
readJSON(t, resp, &created)
id := created.ID
// Update once to create a backup (v1) and bump current to v2.
upd := `<interface id="" name="Test Panel" version="2" w="800" h="600"></interface>`
resp = putRaw(t, srv, "/api/v1/interfaces/"+id, "application/xml", []byte(upd))
assertStatus(t, resp, http.StatusNoContent)
// List versions — at least the backup.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 versions, got %d", len(versions))
}
// Get the v1 backup.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version path → 400.
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/abc")
assertStatus(t, resp, http.StatusBadRequest)
// Tag v1.
resp = putRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/tag?tag=golden", "application/json", nil)
assertStatus(t, resp, http.StatusNoContent)
// Promote v1 → becomes new current.
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusNoContent)
// Fork v1 → brand-new panel.
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == id {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Version ops on a missing panel → 404.
resp = get(t, srv, "/api/v1/interfaces/missing/versions")
assertStatus(t, resp, http.StatusNotFound)
}
// ── /controllogic ─────────────────────────────────────────────────────────────
func TestControlLogicCRUD(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Empty list initially.
resp := get(t, srv, "/api/v1/controllogic")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no control logic, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "Watchdog", "enabled": true})
assertStatus(t, resp, http.StatusCreated)
var g struct {
ID string `json:"id"`
Version int `json:"version"`
}
readJSON(t, resp, &g)
if g.ID == "" {
t.Fatal("expected control logic id")
}
// Get.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/controllogic/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (bumps version, creates a backup).
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json",
[]byte(`{"name":"Watchdog v2","enabled":false}`))
assertStatus(t, resp, http.StatusOK)
// List versions.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 control-logic versions, got %d", len(versions))
}
// Promote v1.
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
// Fork v1 → new graph id.
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
var fork struct {
ID string `json:"id"`
}
readJSON(t, resp, &fork)
if fork.ID == "" || fork.ID == g.ID {
t.Errorf("fork id = %q, want fresh id", fork.ID)
}
// Delete.
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
assertStatus(t, resp, http.StatusNotFound)
}
// ── /audit (Nop audit log, anonymous can view on an open policy) ───────────────
func TestGetAuditOpen(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := get(t, srv, "/api/v1/audit")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad start time → 400.
resp = get(t, srv, "/api/v1/audit?start=not-a-time")
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /synthetic disabled guards ────────────────────────────────────────────────
// Synthetic is nil in the default harness, so every route must report 503
// (service unavailable) rather than panicking.
func TestSyntheticDisabledRoutes(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
cases := []struct {
method, path string
body []byte
}{
{http.MethodGet, "/api/v1/synthetic/x", nil},
{http.MethodPut, "/api/v1/synthetic/x", []byte(`{}`)},
{http.MethodDelete, "/api/v1/synthetic/x", nil},
{http.MethodPost, "/api/v1/synthetic/trace", []byte(`{}`)},
{http.MethodGet, "/api/v1/synthetic/x/versions", nil},
{http.MethodGet, "/api/v1/synthetic/x/versions/1", nil},
{http.MethodPost, "/api/v1/synthetic/x/versions/1/promote", nil},
{http.MethodPost, "/api/v1/synthetic/x/versions/1/fork", nil},
}
for _, c := range cases {
var resp *http.Response
switch c.method {
case http.MethodGet:
resp = get(t, srv, c.path)
case http.MethodPut:
resp = putRaw(t, srv, c.path, "application/json", c.body)
case http.MethodDelete:
resp = deleteReq(t, srv, c.path)
case http.MethodPost:
resp = postRaw(t, srv, c.path, "application/json", c.body)
}
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("%s %s: status %d, want 503", c.method, c.path, resp.StatusCode)
}
resp.Body.Close()
}
}
// ── /controllogic/{id}/versions/{version} ─────────────────────────────────────
func TestControlLogicGetVersion(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
resp := postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "G"})
assertStatus(t, resp, http.StatusCreated)
var g struct {
ID string `json:"id"`
}
readJSON(t, resp, &g)
// Bump so a v1 backup exists.
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json", []byte(`{"name":"G2"}`))
assertStatus(t, resp, http.StatusOK)
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version number → 400.
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
}
// ── /config/instances/diff ────────────────────────────────────────────────────
func TestDiffConfigInstancesMissing(t *testing.T) {
srv, teardown := setup(t)
defer teardown()
// Missing left id → 400.
resp := get(t, srv, "/api/v1/config/instances/diff?a=&b=")
assertStatus(t, resp, http.StatusBadRequest)
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
)
// TestExtractLogicBlock covers the three branches of the <logic> extractor.
func TestExtractLogicBlock(t *testing.T) {
if got := extractLogicBlock([]byte(`<i><logic><n/></logic></i>`)); got != "<logic><n/></logic>" {
t.Errorf("extract = %q", got)
}
if got := extractLogicBlock([]byte(`<i></i>`)); got != "" {
t.Errorf("no logic: want empty, got %q", got)
}
if got := extractLogicBlock([]byte(`<i><logic>unterminated`)); got != "" {
t.Errorf("unterminated: want empty, got %q", got)
}
}
// TestDataTypeName covers every DataType→token mapping plus the default.
func TestDataTypeName(t *testing.T) {
cases := map[datasource.DataType]string{
datasource.TypeFloat64: "float64",
datasource.TypeFloat64Array: "float64[]",
datasource.TypeString: "string",
datasource.TypeInt64: "int64",
datasource.TypeBool: "bool",
datasource.TypeEnum: "enum",
datasource.DataType(255): "unknown",
}
for typ, want := range cases {
if got := dataTypeName(typ); got != want {
t.Errorf("dataTypeName(%v) = %q, want %q", typ, got, want)
}
}
}
// TestSynVisible covers each visibility branch of synVisible.
func TestSynVisible(t *testing.T) {
cases := []struct {
name string
def synthetic.SignalDef
user string
panel string
groups []string
want bool
}{
{"user own", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "alice", "", nil, true},
{"user other", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "bob", "", nil, false},
{"panel match", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p1", nil, true},
{"panel mismatch", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p2", nil, false},
{"global", synthetic.SignalDef{Visibility: "global"}, "", "", nil, true},
{"legacy empty", synthetic.SignalDef{}, "", "", nil, true},
{"group member", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"ops"}, true},
{"group outsider", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"hr"}, false},
}
for _, tc := range cases {
if got := synVisible(tc.def, tc.user, tc.panel, tc.groups); got != tc.want {
t.Errorf("%s: synVisible = %v, want %v", tc.name, got, tc.want)
}
}
}
// TestPanelScope covers the nil/public→global, group, and private branches.
func TestPanelScope(t *testing.T) {
if sc, _ := panelScope(nil); sc != access.ScopeGlobal {
t.Errorf("nil acl: scope = %q, want global", sc)
}
if sc, _ := panelScope(&panelacl.PanelACL{Public: "read"}); sc != access.ScopeGlobal {
t.Errorf("public acl: scope = %q, want global", sc)
}
sc, groups := panelScope(&panelacl.PanelACL{
Grants: []panelacl.Grant{{Kind: "group", Name: "ops"}, {Kind: "user", Name: "x"}},
})
if sc != access.ScopeGroup || len(groups) != 1 || groups[0] != "ops" {
t.Errorf("group acl: scope=%q groups=%v", sc, groups)
}
if sc, _ := panelScope(&panelacl.PanelACL{Owner: "alice"}); sc != access.ScopePrivate {
t.Errorf("private acl: scope = %q, want private", sc)
}
}
// TestClientIP covers the XFF, X-Real-IP, host:port, and bare-RemoteAddr cases.
func TestClientIP(t *testing.T) {
mk := func(set func(*http.Request)) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/", nil)
set(r)
return r
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") })); got != "1.2.3.4" {
t.Errorf("XFF list: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "9.9.9.9") })); got != "9.9.9.9" {
t.Errorf("XFF single: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Real-IP", "8.8.8.8") })); got != "8.8.8.8" {
t.Errorf("X-Real-IP: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "10.0.0.1:5555" })); got != "10.0.0.1" {
t.Errorf("host:port: got %q", got)
}
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "bare-addr" })); got != "bare-addr" {
t.Errorf("bare addr: got %q", got)
}
}
+175
View File
@@ -0,0 +1,175 @@
package api_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/confmgr"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource/stub"
"github.com/uopi/uopi/internal/datasource/synthetic"
"github.com/uopi/uopi/internal/panelacl"
"github.com/uopi/uopi/internal/storage"
)
// setupSynthetic builds an API server whose synthetic data source is enabled, so
// the synthetic CRUD/versioning/trace handlers run their real bodies.
func setupSynthetic(t *testing.T) (*httptest.Server, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
log := slog.New(slog.NewTextHandler(io.Discard, nil))
brk := broker.New(ctx, log)
ds := stub.New()
if err := ds.Connect(ctx); err != nil {
t.Fatal("stub connect:", err)
}
brk.Register(ds)
dir := t.TempDir()
store, _ := storage.New(dir)
acl, _ := panelacl.New(dir)
clStore, _ := controllogic.NewStore(dir)
cfgStore, _ := confmgr.New(dir)
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
synthDS := synthetic.New(dir, brk, log)
if err := synthDS.Connect(ctx); err != nil {
t.Fatal("synthetic connect:", err)
}
brk.Register(synthDS)
mux := http.NewServeMux()
api.New(brk, synthDS, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
srv := httptest.NewServer(mux)
return srv, func() { srv.Close(); cancel() }
}
// putJSON marshals body and issues a PUT, mirroring postJSON.
func putJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal("json.Marshal:", err)
}
return putRaw(t, srv, path, "application/json", b)
}
// syntheticBody returns a minimal valid graph signal sourced from the stub's
// "sine" signal through a gain op.
func syntheticBody(name string) map[string]any {
return map[string]any{
"name": name,
"visibility": "global",
"graph": map[string]any{
"output": "out",
"nodes": []map[string]any{
{"id": "a", "kind": "source", "ds": "stub", "signal": "sine"},
{"id": "g", "kind": "op", "op": "gain", "inputs": []string{"a"},
"params": map[string]any{"k": 2.0}},
{"id": "out", "kind": "output", "inputs": []string{"g"}},
},
},
}
}
func TestSyntheticCRUDEnabled(t *testing.T) {
srv, teardown := setupSynthetic(t)
defer teardown()
// List — initially empty.
resp := get(t, srv, "/api/v1/synthetic")
assertStatus(t, resp, http.StatusOK)
var list []any
readJSON(t, resp, &list)
if len(list) != 0 {
t.Fatalf("expected no synthetic signals, got %d", len(list))
}
// Create.
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Duplicate create → 400 (AddSignal rejects an existing name).
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
assertStatus(t, resp, http.StatusBadRequest)
// Invalid JSON → 400.
resp = postRaw(t, srv, "/api/v1/synthetic", "application/json", []byte(`{bad`))
assertStatus(t, resp, http.StatusBadRequest)
// Get.
resp = get(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Get missing → 404.
resp = get(t, srv, "/api/v1/synthetic/nope")
assertStatus(t, resp, http.StatusNotFound)
// Update (changes gain factor).
upd := syntheticBody("doubled")
upd["graph"].(map[string]any)["nodes"].([]map[string]any)[1]["params"] = map[string]any{"k": 3.0}
resp = putJSON(t, srv, "/api/v1/synthetic/doubled", upd)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Update missing → 404.
resp = putJSON(t, srv, "/api/v1/synthetic/nope", syntheticBody("nope"))
assertStatus(t, resp, http.StatusNotFound)
// Versions list (the update created a backup).
resp = get(t, srv, "/api/v1/synthetic/doubled/versions")
assertStatus(t, resp, http.StatusOK)
var versions []map[string]any
readJSON(t, resp, &versions)
if len(versions) < 2 {
t.Fatalf("expected >=2 synthetic versions, got %d", len(versions))
}
// Get v1.
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/1")
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Bad version → 400.
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/xyz")
assertStatus(t, resp, http.StatusBadRequest)
// Promote v1.
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/promote", "application/json", nil)
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Fork v1 → new signal.
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/fork", "application/json", nil)
assertStatus(t, resp, http.StatusCreated)
resp.Body.Close()
// Trace an unsaved graph.
resp = postJSON(t, srv, "/api/v1/synthetic/trace", syntheticBody("scratch"))
assertStatus(t, resp, http.StatusOK)
resp.Body.Close()
// Trace with invalid JSON → 400.
resp = postRaw(t, srv, "/api/v1/synthetic/trace", "application/json", []byte(`{bad`))
assertStatus(t, resp, http.StatusBadRequest)
// Delete.
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusNoContent)
// Delete missing → 404.
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
assertStatus(t, resp, http.StatusNotFound)
}
+91
View File
@@ -0,0 +1,91 @@
package audit
import (
"log/slog"
"path/filepath"
"testing"
"time"
)
// TestNopRecorder covers the disabled-auditing no-op implementation.
func TestNopRecorder(t *testing.T) {
rec := Nop()
rec.Record(Event{Action: "x"}) // must not panic
got, err := rec.Query(Filter{})
if err != nil {
t.Fatalf("Nop Query: %v", err)
}
if len(got) != 0 {
t.Errorf("Nop Query returned %d events, want 0", len(got))
}
if err := rec.Close(); err != nil {
t.Errorf("Nop Close: %v", err)
}
}
// TestQueryFilters covers the End, DS, and Signal (LIKE) filter branches plus
// the default-outcome and zero-time stamping in Record.
func TestQueryFilters(t *testing.T) {
path := filepath.Join(t.TempDir(), "audit.db")
rec, err := NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("NewSQLite:", err)
}
defer rec.Close()
// Anchor base in the past so the zero-Time 'c' record (stamped with now)
// sorts after a/b and stays out of the End window below.
base := time.Now().Add(-time.Hour)
rec.Record(Event{Time: base, Actor: "a", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:TEMP"})
rec.Record(Event{Time: base.Add(time.Second), Actor: "b", ActorType: ActorUser, Action: "signal.write", DS: "synthetic", Signal: "PV:FLOW"})
// Zero Time and empty Outcome exercise the defaulting branches in Record.
rec.Record(Event{Actor: "c", ActorType: ActorUser, Action: "interface.update"})
if err := rec.Close(); err != nil {
t.Fatal("Close:", err)
}
rec, err = NewSQLite(path, slog.Default())
if err != nil {
t.Fatal("reopen:", err)
}
defer rec.Close()
// End filter: only events at or before base.
upTo, err := rec.Query(Filter{End: base.Add(500 * time.Millisecond)})
if err != nil {
t.Fatal("Query end:", err)
}
if len(upTo) != 1 || upTo[0].Actor != "a" {
t.Errorf("end filter = %+v, want one 'a' event", upTo)
}
// DS filter.
byDS, err := rec.Query(Filter{DS: "synthetic"})
if err != nil {
t.Fatal("Query ds:", err)
}
if len(byDS) != 1 || byDS[0].Signal != "PV:FLOW" {
t.Errorf("ds filter = %+v, want one PV:FLOW event", byDS)
}
// Signal LIKE filter (substring match).
bySignal, err := rec.Query(Filter{Signal: "TEMP"})
if err != nil {
t.Fatal("Query signal:", err)
}
if len(bySignal) != 1 || bySignal[0].DS != "epics" {
t.Errorf("signal filter = %+v, want one epics event", bySignal)
}
// The zero-time/empty-outcome record was persisted with a default outcome.
def, err := rec.Query(Filter{Actor: "c"})
if err != nil {
t.Fatal("Query actor c:", err)
}
if len(def) != 1 || def[0].Outcome != OutcomeOK {
t.Errorf("defaulted record = %+v, want one event with outcome ok", def)
}
if def[0].Time.IsZero() {
t.Error("zero Time should have been stamped with now")
}
}
+48
View File
@@ -0,0 +1,48 @@
package broker_test
import (
"context"
"testing"
"time"
"github.com/uopi/uopi/internal/broker"
)
// TestDataSourcesAndSource covers the registry accessors.
func TestDataSourcesAndSource(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
all := b.DataSources()
if len(all) != 1 || all[0].Name() != "stub" {
t.Fatalf("DataSources = %v, want one 'stub'", all)
}
if ds, ok := b.Source("stub"); !ok || ds.Name() != "stub" {
t.Errorf("Source(stub) = %v,%v want stub,true", ds, ok)
}
if _, ok := b.Source("nope"); ok {
t.Error("Source(nope): want ok=false")
}
}
// TestReadNow covers the one-shot read happy path plus the unknown-DS and
// context-timeout error branches.
func TestReadNow(t *testing.T) {
b, cancel := newBroker(t)
defer cancel()
ctx, c := context.WithTimeout(context.Background(), time.Second)
defer c()
v, err := b.ReadNow(ctx, broker.SignalRef{DS: "stub", Name: "sine_1hz"})
if err != nil {
t.Fatalf("ReadNow: %v", err)
}
if v.Timestamp.IsZero() {
t.Error("ReadNow returned a zero-timestamp value")
}
// Unknown data source.
if _, err := b.ReadNow(ctx, broker.SignalRef{DS: "ghost", Name: "x"}); err == nil {
t.Error("ReadNow(unknown ds): want error")
}
}
+189 -29
View File
@@ -7,40 +7,54 @@ import (
"strings"
"github.com/BurntSushi/toml"
"github.com/uopi/uopi/internal/datasource/modbus"
"github.com/uopi/uopi/internal/datasource/scpi"
)
type Config struct {
Server ServerConfig `toml:"server"`
Datasource DatasourceConfig `toml:"datasource"`
Audit AuditConfig `toml:"audit"`
UI UIConfig `toml:"ui"`
// Groups are named sets of users, referenced by panel sharing rules.
Groups []GroupDef `toml:"groups"`
}
// UIConfig carries client-side presentation defaults sent to the browser at
// startup (via /api/v1/me).
type UIConfig struct {
// DefaultZoom is the base UI scale multiplier applied when a browser has no
// per-machine zoom override saved. Useful to enlarge the UI by default on
// HiDPI screens whose OS scaling is left at 100% (where the browser reports
// devicePixelRatio=1 and the UI would otherwise render small). The in-app
// A+/A control still overrides it locally. 0 or unset means 1.0 (no scaling).
DefaultZoom float64 `toml:"default_zoom"`
}
// AuditConfig controls the audit trail. When enabled, every user and automated
// action that could affect the controlled system (signal writes, control-logic
// changes) is recorded to a SQLite database for later review by audit staff.
// changes) is recorded to a SQLite database for later review by audit staff. Who
// may *view* the log is governed by the auditor role (see GroupDef).
type AuditConfig struct {
Enabled bool `toml:"enabled"`
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
// Readers lists users or group names allowed to view the audit log. Empty
// leaves viewing unrestricted (any caller with read access). Anonymous /
// trusted-LAN callers are always permitted.
Readers []string `toml:"readers"`
}
// GroupDef is a named set of users defined as [[groups]] in the config file.
// GroupDef defines one access group as [[groups]] in the config file. Each group
// has an optional parent (for nesting) and lists its members by role. Roles form
// a cumulative ladder: viewer < operator < logiceditor < auditor < admin. A user
// listed in several role buckets keeps the highest. The built-in "public" group
// (every user is an implicit viewer member) may be configured by name to raise
// specific users globally.
type GroupDef struct {
Name string `toml:"name"`
Members []string `toml:"members"`
}
// BlacklistEntry downgrades a specific user's global access level. Levels:
// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
// with full access.
type BlacklistEntry struct {
User string `toml:"user"`
Level string `toml:"level"`
Parent string `toml:"parent"`
Viewers []string `toml:"viewers"`
Operators []string `toml:"operators"`
LogicEditors []string `toml:"logiceditors"`
Auditors []string `toml:"auditors"`
Admins []string `toml:"admins"`
}
type ServerConfig struct {
@@ -58,18 +72,124 @@ type ServerConfig struct {
// DefaultUser is the identity used when the trusted user header is absent or
// empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
//
// Access levels (write/readonly), logic-edit, audit-view and admin rights are
// all granted via group roles (see GroupDef / [[groups]]). A config with no
// group roles at all is treated as fully open (everyone is admin), so an
// unconfigured deployment behaves like trusted LAN. Once the admin pane writes
// {storage_dir}/access.json, that file — not this config — is the source of
// truth for access.
DefaultUser string `toml:"default_user"`
// Blacklist downgrades specific users' global access level. Everyone not
// listed is trusted with full write access.
Blacklist []BlacklistEntry `toml:"blacklist"`
// Kerberos enables native SPNEGO authentication (see KerberosConfig).
Kerberos KerberosConfig `toml:"kerberos"`
// LogicEditors optionally restricts who may add or edit panel logic (the
// <logic> block of interfaces) and server-side control logic. Entries are
// usernames or group names. When empty, no restriction applies (any user
// with write access may edit logic). Anonymous/trusted-LAN callers are
// always permitted.
LogicEditors []string `toml:"logic_editors"`
// BasicAuth enables built-in HTTP Basic authentication validated against PAM
// (see BasicAuthConfig). Mutually exclusive with Kerberos.
BasicAuth BasicAuthConfig `toml:"basic_auth"`
// LDAP enables built-in HTTP Basic authentication validated against an LDAP
// directory (see LDAPConfig). Pure-Go alternative to BasicAuth/PAM that keeps
// the static binary. Mutually exclusive with Kerberos and BasicAuth.
LDAP LDAPConfig `toml:"ldap"`
// TLS enables built-in HTTPS (see TLSConfig). Strongly recommended whenever
// BasicAuth is enabled, since Basic credentials are sent on every request.
TLS TLSConfig `toml:"tls"`
}
// KerberosConfig enables native SPNEGO/Kerberos ("Negotiate") authentication so
// uopi identifies users directly from their Kerberos ticket, without depending on
// a separate auth proxy to set TrustedUserHeader. This is the recommended setup
// for browsers like Firefox that do not silently fall back to a proxy default:
// uopi answers API requests with a 401 WWW-Authenticate: Negotiate challenge, the
// browser performs the SPNEGO handshake, and uopi resolves the user from the
// validated ticket (short principal name, realm stripped). That username feeds
// the same access pipeline as TrustedUserHeader.
//
// Browsers must be told to perform SPNEGO for this server's origin (Firefox:
// network.negotiate-auth.trusted-uris; Chrome/Edge: AuthServerAllowlist policy or
// OS integrated auth). When enabled, any inbound TrustedUserHeader value is
// ignored in favour of the Kerberos identity to prevent spoofing.
type KerberosConfig struct {
Enabled bool `toml:"enabled"`
// Keytab is the path to the service keytab holding the HTTP service
// principal's long-term key (e.g. HTTP/host.example.com@REALM). Required when
// Enabled.
Keytab string `toml:"keytab"`
// ServicePrincipal optionally selects which principal in the keytab to accept
// tickets for (e.g. "HTTP/host.example.com"). Empty accepts the keytab's
// entries by default.
ServicePrincipal string `toml:"service_principal"`
}
// BasicAuthConfig enables uopi's built-in HTTP Basic authentication: uopi
// answers API requests with 401 WWW-Authenticate: Basic, the browser prompts for
// a username/password, and uopi validates them through the host PAM stack
// (/etc/pam.d/<PAMService>). On hosts that are SSSD/LDAP clients this reuses the
// users' normal login credentials with no directory configuration in uopi. The
// validated username feeds the same access pipeline as TrustedUserHeader.
//
// PAM support requires a cgo build with the `pam` tag (make backend-pam); the
// default fully-static binary cannot validate and will refuse to start with
// BasicAuth enabled. Because Basic credentials travel on every request, enable
// TLS (see TLSConfig) unless uopi sits on a fully isolated network.
type BasicAuthConfig struct {
Enabled bool `toml:"enabled"`
// PAMService is the PAM service name under /etc/pam.d/ to authenticate
// against. Empty defaults to "uopi".
PAMService string `toml:"pam_service"`
}
// LDAPConfig enables uopi's built-in HTTP Basic authentication validated against
// an LDAP directory via the "search then bind" pattern — the same flow an
// SSSD/LDAP client uses. Because it speaks LDAP over the wire with no cgo, it
// works in the default fully-static binary (unlike the PAM backend), while still
// authenticating users against the same directory the host logs in with. The
// validated username feeds the same access pipeline as TrustedUserHeader. Enable
// TLS (ldaps:// or StartTLS) so passwords are not sent in clear text.
//
// Defaults mirror SSSD: empty UserAttr → "uid", empty UserObjectClass →
// "posixAccount", empty BindDN → anonymous search. Mutually exclusive with
// Kerberos and BasicAuth.
type LDAPConfig struct {
Enabled bool `toml:"enabled"`
// URIs are the directory endpoints (SSSD ldap_uri), tried in order, e.g.
// "ldaps://ldap.example.com". Required when Enabled.
URIs []string `toml:"uri"`
// SearchBase is the subtree user entries live under (SSSD ldap_search_base).
// Required when Enabled.
SearchBase string `toml:"search_base"`
// UserAttr is the attribute matched against the login name. Empty → "uid".
UserAttr string `toml:"user_attr"`
// UserObjectClass restricts the search. Empty → "posixAccount".
UserObjectClass string `toml:"user_object_class"`
// BindDN / BindPassword optionally authenticate the search (service account).
// Empty BindDN performs an anonymous search.
BindDN string `toml:"bind_dn"`
BindPassword string `toml:"bind_password"`
// StartTLS upgrades an ldap:// connection to TLS before binding. Ignored for
// ldaps://.
StartTLS bool `toml:"start_tls"`
// CACert is an optional PEM CA bundle to trust (private CA).
CACert string `toml:"ca_cert"`
// InsecureSkipVerify disables TLS certificate verification. Testing only.
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
}
// TLSConfig enables built-in HTTPS so uopi can terminate TLS itself (e.g. for
// Basic auth) without a reverse proxy. When Enabled, Cert and Key are required.
type TLSConfig struct {
Enabled bool `toml:"enabled"`
// Cert and Key are paths to the PEM certificate and private key.
Cert string `toml:"cert"`
Key string `toml:"key"`
// RedirectFrom, when set (e.g. ":8080"), starts an additional plain-HTTP
// listener on that address that 301-redirects every request to the HTTPS
// service. Without it, a browser that connects with http:// gets the opaque
// "client sent an HTTP request to an HTTPS server" error instead of being
// upgraded. Empty disables the redirector.
RedirectFrom string `toml:"redirect_from"`
}
type DatasourceConfig struct {
@@ -77,6 +197,8 @@ type DatasourceConfig struct {
EPICS EPICSConfig `toml:"epics"`
PVA PVAConfig `toml:"pva"`
Synthetic SyntheticConfig `toml:"synthetic"`
Modbus modbus.Config `toml:"modbus"`
SCPI scpi.Config `toml:"scpi"`
}
type StubConfig struct {
@@ -151,8 +273,44 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
cfg.Server.DefaultUser = v
}
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
cfg.Server.LogicEditors = strings.Fields(v)
if v := env("UOPI_SERVER_KERBEROS_ENABLED"); v != "" {
cfg.Server.Kerberos.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_SERVER_KERBEROS_KEYTAB"); v != "" {
cfg.Server.Kerberos.Keytab = v
}
if v := env("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL"); v != "" {
cfg.Server.Kerberos.ServicePrincipal = v
}
if v := env("UOPI_SERVER_BASIC_AUTH_ENABLED"); v != "" {
cfg.Server.BasicAuth.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE"); v != "" {
cfg.Server.BasicAuth.PAMService = v
}
if v := env("UOPI_SERVER_TLS_ENABLED"); v != "" {
cfg.Server.TLS.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_SERVER_TLS_CERT"); v != "" {
cfg.Server.TLS.Cert = v
}
if v := env("UOPI_SERVER_TLS_KEY"); v != "" {
cfg.Server.TLS.Key = v
}
if v := env("UOPI_SERVER_LDAP_ENABLED"); v != "" {
cfg.Server.LDAP.Enabled = (v == "true" || v == "YES")
}
if v := env("UOPI_SERVER_LDAP_URI"); v != "" {
cfg.Server.LDAP.URIs = strings.Fields(v)
}
if v := env("UOPI_SERVER_LDAP_SEARCH_BASE"); v != "" {
cfg.Server.LDAP.SearchBase = v
}
if v := env("UOPI_SERVER_LDAP_BIND_DN"); v != "" {
cfg.Server.LDAP.BindDN = v
}
if v := env("UOPI_SERVER_LDAP_BIND_PASSWORD"); v != "" {
cfg.Server.LDAP.BindPassword = v
}
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
cfg.Audit.Enabled = (v == "true" || v == "YES")
@@ -160,9 +318,6 @@ func applyEnv(cfg *Config) {
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
cfg.Audit.DBPath = v
}
if v := env("UOPI_AUDIT_READERS"); v != "" {
cfg.Audit.Readers = strings.Fields(v)
}
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
cfg.Datasource.EPICS.CAAddrList = v
}
@@ -181,6 +336,11 @@ func applyEnv(cfg *Config) {
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
cfg.Datasource.PVA.AddrList = strings.Fields(v)
}
if v := env("UOPI_UI_DEFAULT_ZOOM"); v != "" {
if z, err := strconv.ParseFloat(v, 64); err == nil {
cfg.UI.DefaultZoom = z
}
}
}
func env(key string) string {
+82
View File
@@ -112,6 +112,88 @@ func TestEnvOverrides(t *testing.T) {
}
}
func TestEnvOverridesAuthTLSAndMisc(t *testing.T) {
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "25.5")
t.Setenv("UOPI_SERVER_TRUSTED_USER_HEADER", "X-Forwarded-User")
t.Setenv("UOPI_SERVER_DEFAULT_USER", "svc")
t.Setenv("UOPI_SERVER_KERBEROS_ENABLED", "true")
t.Setenv("UOPI_SERVER_KERBEROS_KEYTAB", "/etc/krb.keytab")
t.Setenv("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL", "HTTP/host")
t.Setenv("UOPI_SERVER_BASIC_AUTH_ENABLED", "YES")
t.Setenv("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE", "login")
t.Setenv("UOPI_SERVER_TLS_ENABLED", "true")
t.Setenv("UOPI_SERVER_TLS_CERT", "/c.pem")
t.Setenv("UOPI_SERVER_TLS_KEY", "/k.pem")
t.Setenv("UOPI_SERVER_LDAP_ENABLED", "true")
t.Setenv("UOPI_SERVER_LDAP_URI", "ldaps://a ldaps://b")
t.Setenv("UOPI_SERVER_LDAP_SEARCH_BASE", "dc=x,dc=y")
t.Setenv("UOPI_SERVER_LDAP_BIND_DN", "cn=svc")
t.Setenv("UOPI_SERVER_LDAP_BIND_PASSWORD", "secret")
t.Setenv("UOPI_AUDIT_ENABLED", "true")
t.Setenv("UOPI_AUDIT_DB_PATH", "/audit.db")
t.Setenv("UOPI_EPICS_AUTO_SYNC_FILTER", "area=SR")
t.Setenv("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER", "true")
t.Setenv("EPICS_PVA_ADDR_LIST", "10.1.1.1 10.1.1.2")
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "1.5")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.MaxUpdateRateHz != 25.5 {
t.Errorf("MaxUpdateRateHz = %v, want 25.5", cfg.Server.MaxUpdateRateHz)
}
if cfg.Server.TrustedUserHeader != "X-Forwarded-User" {
t.Errorf("TrustedUserHeader = %q", cfg.Server.TrustedUserHeader)
}
if cfg.Server.DefaultUser != "svc" {
t.Errorf("DefaultUser = %q", cfg.Server.DefaultUser)
}
if !cfg.Server.Kerberos.Enabled || cfg.Server.Kerberos.Keytab != "/etc/krb.keytab" || cfg.Server.Kerberos.ServicePrincipal != "HTTP/host" {
t.Errorf("Kerberos = %+v", cfg.Server.Kerberos)
}
if !cfg.Server.BasicAuth.Enabled || cfg.Server.BasicAuth.PAMService != "login" {
t.Errorf("BasicAuth = %+v", cfg.Server.BasicAuth)
}
if !cfg.Server.TLS.Enabled || cfg.Server.TLS.Cert != "/c.pem" || cfg.Server.TLS.Key != "/k.pem" {
t.Errorf("TLS = %+v", cfg.Server.TLS)
}
if !cfg.Server.LDAP.Enabled || len(cfg.Server.LDAP.URIs) != 2 ||
cfg.Server.LDAP.SearchBase != "dc=x,dc=y" || cfg.Server.LDAP.BindDN != "cn=svc" ||
cfg.Server.LDAP.BindPassword != "secret" {
t.Errorf("LDAP = %+v", cfg.Server.LDAP)
}
if !cfg.Audit.Enabled || cfg.Audit.DBPath != "/audit.db" {
t.Errorf("Audit = %+v", cfg.Audit)
}
if cfg.Datasource.EPICS.AutoSyncFilter != "area=SR" || !cfg.Datasource.EPICS.AutoSyncFromArchiver {
t.Errorf("EPICS sync = %+v", cfg.Datasource.EPICS)
}
if len(cfg.Datasource.PVA.AddrList) != 2 {
t.Errorf("PVA AddrList = %+v", cfg.Datasource.PVA.AddrList)
}
if cfg.UI.DefaultZoom != 1.5 {
t.Errorf("UI.DefaultZoom = %v, want 1.5", cfg.UI.DefaultZoom)
}
}
func TestEnvInvalidNumbersIgnored(t *testing.T) {
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "not-a-number")
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "xyz")
cfg, err := config.Load("")
if err != nil {
t.Fatalf("Load: %v", err)
}
// Invalid floats are ignored, leaving the defaults intact.
if cfg.Server.MaxUpdateRateHz != config.Default().Server.MaxUpdateRateHz {
t.Errorf("invalid MaxUpdateRateHz should be ignored, got %v", cfg.Server.MaxUpdateRateHz)
}
if cfg.UI.DefaultZoom != config.Default().UI.DefaultZoom {
t.Errorf("invalid DefaultZoom should be ignored, got %v", cfg.UI.DefaultZoom)
}
}
func TestEnvTrimsWhitespace(t *testing.T) {
t.Setenv("UOPI_SERVER_LISTEN", " :8888 ")
cfg, err := config.Load("")
+75
View File
@@ -0,0 +1,75 @@
package confmgr
import (
"reflect"
"testing"
)
// TestCoerceSnapshot covers the alternate and error branches of coerceSnapshot
// that the happy-path Snapshot tests do not reach.
func TestCoerceSnapshot(t *testing.T) {
enum := Parameter{Type: TypeEnum, EnumValues: []string{"off", "low", "high"}}
cases := []struct {
name string
p Parameter
raw any
want any
wantErr bool
}{
{"float err", Parameter{Type: TypeFloat}, struct{}{}, nil, true},
{"int round", Parameter{Type: TypeInt}, 2.6, int64(3), false},
{"int err", Parameter{Type: TypeInt}, struct{}{}, nil, true},
{"bool from string", Parameter{Type: TypeBool}, "true", true, false},
{"bool bad string", Parameter{Type: TypeBool}, "maybe", nil, true},
{"bool from numeric", Parameter{Type: TypeBool}, 0.0, false, false},
{"bool unconvertible", Parameter{Type: TypeBool}, struct{}{}, nil, true},
{"string passthrough", Parameter{Type: TypeString}, "x", "x", false},
{"string from numeric", Parameter{Type: TypeString}, 42.0, "42", false},
{"enum string in range", enum, "low", "low", false},
{"enum string out of range", enum, "nope", nil, true},
{"enum index", enum, int64(2), "high", false},
{"enum index out of range", enum, 9.0, nil, true},
{"enum non-numeric", enum, struct{}{}, nil, true},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, []float64{1, 2}, false},
{"array err", Parameter{Type: TypeFloatArray}, 1.0, nil, true},
{"default passthrough", Parameter{Type: ParamType("weird")}, "asis", "asis", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.p.coerceSnapshot(tc.raw)
if tc.wantErr {
if err == nil {
t.Errorf("coerceSnapshot(%v): want error", tc.raw)
}
return
}
if err != nil {
t.Fatalf("coerceSnapshot(%v): %v", tc.raw, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("coerceSnapshot(%v) = %v (%T), want %v (%T)", tc.raw, got, got, tc.want, tc.want)
}
})
}
}
// TestSnapshotCoerceFailureRecorded ensures a coercion failure (vs read failure)
// is counted in res.Failed and kept out of Values.
func TestSnapshotCoerceFailureRecorded(t *testing.T) {
set := ConfigSet{
ID: "s",
Name: "s",
Parameters: []Parameter{
{Key: "n", DS: "d", Signal: "N", Type: TypeInt},
},
}
res := Snapshot(set, func(_, _ string) (any, error) {
return "not-a-number", nil // reads fine, fails coercion
})
if res.Captured != 0 || res.Failed != 1 {
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
}
if _, ok := res.Values["n"]; ok {
t.Error("coercion-failed parameter must not appear in values")
}
}
+8
View File
@@ -24,12 +24,20 @@ type ConfigRule struct {
Name string `json:"name"`
SetID string `json:"setId"`
Description string `json:"description,omitempty"`
// Enabled gates whether the rule runs when instances of the bound set are
// saved/applied. A nil pointer (legacy rules created before the flag) is
// treated as enabled, so existing rules keep their behaviour.
Enabled *bool `json:"enabled,omitempty"`
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Source string `json:"source"`
}
// IsEnabled reports whether the rule participates in instance evaluation. A nil
// Enabled flag (legacy rules) is treated as enabled.
func (r ConfigRule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled }
// 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)
+4
View File
@@ -61,6 +61,8 @@ type ConfigSet struct {
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility
Parameters []Parameter `json:"parameters"`
}
@@ -75,6 +77,8 @@ type ConfigInstance struct {
Version int `json:"version"`
Tag string `json:"tag,omitempty"`
Owner string `json:"owner,omitempty"`
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility
Values map[string]any `json:"values"`
}
+134
View File
@@ -0,0 +1,134 @@
package confmgr
import (
"testing"
)
// TestParamTypeValid covers the valid/invalid branches of ParamType.valid.
func TestParamTypeValid(t *testing.T) {
for _, ok := range []ParamType{TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray} {
if !ok.valid() {
t.Errorf("%q should be valid", ok)
}
}
if ParamType("bogus").valid() {
t.Error("bogus type should be invalid")
}
}
// TestCheckValue exercises every type branch of Parameter.checkValue, including
// the type-mismatch and range-violation error paths.
func TestCheckValue(t *testing.T) {
cases := []struct {
name string
p Parameter
v any
wantErr bool
}{
{"float ok", Parameter{Type: TypeFloat}, 1.5, false},
{"float from string", Parameter{Type: TypeFloat}, "2.5", false},
{"float not numeric", Parameter{Type: TypeFloat}, true, true},
{"float below min", Parameter{Type: TypeFloat, Min: fptr(0)}, -1.0, true},
{"float above max", Parameter{Type: TypeFloat, Max: fptr(10)}, 11.0, true},
{"int ok", Parameter{Type: TypeInt}, 4.0, false},
{"int non-integer", Parameter{Type: TypeInt}, 4.5, true},
{"bool ok", Parameter{Type: TypeBool}, true, false},
{"bool wrong type", Parameter{Type: TypeBool}, 1.0, true},
{"string ok", Parameter{Type: TypeString}, "hi", false},
{"string wrong type", Parameter{Type: TypeString}, 1.0, true},
{"enum ok", Parameter{Type: TypeEnum, EnumValues: []string{"a", "b"}}, "b", false},
{"enum not string", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, 1.0, true},
{"enum not allowed", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, "z", true},
{"array ok", Parameter{Type: TypeFloatArray}, []any{1.0, 2.0}, false},
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, false},
{"array not array", Parameter{Type: TypeFloatArray}, 1.0, true},
{"array elem below min", Parameter{Type: TypeFloatArray, Min: fptr(0)}, []any{-1.0}, true},
{"array elem above max", Parameter{Type: TypeFloatArray, Max: fptr(5)}, []any{9.0}, true},
{"array bad elem", Parameter{Type: TypeFloatArray}, []any{"nope"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.p.checkValue(tc.v)
if tc.wantErr && err == nil {
t.Errorf("checkValue(%v): want error", tc.v)
}
if !tc.wantErr && err != nil {
t.Errorf("checkValue(%v): unexpected error %v", tc.v, err)
}
})
}
}
// TestValidateInvalidType covers the invalid-type branch of ConfigSet.Validate.
func TestValidateInvalidType(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: ParamType("weird")},
}}
if err := set.Validate(); err == nil {
t.Error("invalid parameter type: want error")
}
}
// TestValidateEnumRequiresValues covers the enum-without-values branch.
func TestValidateEnumRequiresValues(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeEnum},
}}
if err := set.Validate(); err == nil {
t.Error("enum without values: want error")
}
}
// TestValidateMinGreaterThanMax covers the min>max branch.
func TestValidateMinGreaterThanMax(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Min: fptr(10), Max: fptr(1)},
}}
if err := set.Validate(); err == nil {
t.Error("min>max: want error")
}
}
// TestValidateAgainstMandatoryNoDefault covers the mandatory-without-default
// branch of ValidateAgainst.
func TestValidateAgainstMandatoryNoDefault(t *testing.T) {
set := ConfigSet{Name: "x", Parameters: []Parameter{
{Key: "req", DS: "d", Signal: "s", Type: TypeFloat, Mandatory: true},
}}
inst := ConfigInstance{SetID: "x", Values: map[string]any{}}
if err := inst.ValidateAgainst(set); err == nil {
t.Error("missing mandatory value: want error")
}
// Providing the value clears the error.
inst.Values["req"] = 1.0
if err := inst.ValidateAgainst(set); err != nil {
t.Errorf("with value: unexpected error %v", err)
}
}
// TestResolveAndNormalize covers Resolve fallbacks and array normalization.
func TestResolveAndNormalize(t *testing.T) {
p := Parameter{Key: "v", Type: TypeFloat, Default: 7.0}
inst := ConfigInstance{Values: map[string]any{}}
if got, ok := inst.Resolve(p); !ok || got != 7.0 {
t.Errorf("Resolve default: got %v,%v want 7,true", got, ok)
}
inst.Values["v"] = 3.0
if got, ok := inst.Resolve(p); !ok || got != 3.0 {
t.Errorf("Resolve value: got %v,%v want 3,true", got, ok)
}
// Optional param without default → no value.
if _, ok := inst.Resolve(Parameter{Key: "none", Type: TypeFloat}); ok {
t.Error("Resolve no-default: want ok=false")
}
arr := Parameter{Type: TypeFloatArray}
out := arr.normalize([]any{1.0, 2.0, 3.0})
if got, ok := out.([]float64); !ok || len(got) != 3 {
t.Errorf("normalize array: got %T %v", out, out)
}
// Non-array passthrough.
if got := arr.normalize("scalar"); got != "scalar" {
t.Errorf("normalize passthrough: got %v", got)
}
}
+17 -1
View File
@@ -45,6 +45,15 @@ type Meta struct {
// empty for sets. Lets clients group/filter instances by set without a GET
// per instance.
SetID string `json:"setId,omitempty"`
// Enabled is only populated for rules: nil for sets/instances and for legacy
// rules without the flag. Lets the rule list show enabled/disabled status
// without a GET per rule.
Enabled *bool `json:"enabled,omitempty"`
// Owner/Scope/Groups carry the visibility metadata so list handlers can filter
// by the caller without a GET per object. Empty scope = global (legacy-safe).
Owner string `json:"owner,omitempty"`
Scope string `json:"scope,omitempty"`
Groups []string `json:"groups,omitempty"`
}
// VersionMeta describes a single persisted revision.
@@ -85,6 +94,10 @@ type header struct {
Version int `json:"version"`
Tag string `json:"tag"`
SetID string `json:"setId"`
Enabled *bool `json:"enabled"`
Owner string `json:"owner"`
Scope string `json:"scope"`
Groups []string `json:"groups"`
}
func validateID(id string) error {
@@ -149,7 +162,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, SetID: h.SetID})
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled, Owner: h.Owner, Scope: h.Scope, Groups: h.Groups})
}
return out, nil
}
@@ -587,6 +600,9 @@ func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) {
if err != nil {
continue
}
if !rule.IsEnabled() {
continue
}
out = append(out, rule)
}
return out, nil
+118
View File
@@ -0,0 +1,118 @@
package confmgr
import (
"testing"
)
// TestValidateIDRejectsBadChars covers the invalid-character branch of
// validateID, surfaced through the typed getters as ErrNotFound.
func TestValidateIDRejectsBadChars(t *testing.T) {
s, _ := New(t.TempDir())
for _, bad := range []string{"", "bad/slash", "has space", "dot.dot"} {
if _, err := s.GetSet(bad); err == nil {
t.Errorf("GetSet(%q): want error", bad)
}
}
}
// TestNotFoundPaths covers the ErrNotFound branches across the revision API.
func TestNotFoundPaths(t *testing.T) {
s, _ := New(t.TempDir())
if _, err := s.GetSet("missing"); err != ErrNotFound {
t.Errorf("GetSet missing: want ErrNotFound, got %v", err)
}
if _, err := s.GetSetVersion("missing", 1); err != ErrNotFound {
t.Errorf("GetSetVersion missing: want ErrNotFound, got %v", err)
}
if _, err := s.Versions(KindSet, "missing"); err != ErrNotFound {
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
}
if err := s.Promote(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Promote missing: want ErrNotFound, got %v", err)
}
if _, err := s.Fork(KindSet, "missing", 1); err != ErrNotFound {
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
}
if _, err := s.UpdateSet("missing", sampleSet(), ""); err != ErrNotFound {
t.Errorf("UpdateSet missing: want ErrNotFound, got %v", err)
}
// A valid id that exists but a version that was never written.
created, _ := s.CreateSet(sampleSet(), "")
if _, err := s.GetSetVersion(created.ID, 99); err != ErrNotFound {
t.Errorf("GetSetVersion bogus version: want ErrNotFound, got %v", err)
}
}
// TestInstancePinnedSetVersion covers the SetVersion>0 branch of setForInstance:
// the instance is validated against a specific (pinned) revision of its set.
func TestInstancePinnedSetVersion(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
// Bump the set to v2 so a distinct earlier revision exists.
if _, err := s.UpdateSet(set.ID, sampleSet(), "v2"); err != nil {
t.Fatal(err)
}
inst := ConfigInstance{
Name: "pinned",
SetID: set.ID,
SetVersion: 1, // pin to the original schema revision
Values: map[string]any{"voltage": 24.0},
}
out, err := s.CreateInstance(inst, "")
if err != nil {
t.Fatalf("CreateInstance pinned: %v", err)
}
if out.SetVersion != 1 {
t.Errorf("SetVersion: want 1, got %d", out.SetVersion)
}
// Updating the pinned instance also exercises the pinned UpdateInstance path.
out.Values["voltage"] = 30.0
v2, err := s.UpdateInstance(out.ID, out, "bump")
if err != nil {
t.Fatalf("UpdateInstance pinned: %v", err)
}
if v2.Version != 2 {
t.Errorf("instance version: want 2, got %d", v2.Version)
}
}
// TestUpdateInstanceMissingSet covers the load-set error branch of
// UpdateInstance (set referenced by the instance does not exist).
func TestUpdateInstanceMissingSet(t *testing.T) {
s, _ := New(t.TempDir())
inst := ConfigInstance{Name: "x", SetID: "nope", Values: map[string]any{}}
if _, err := s.UpdateInstance("anything", inst, ""); err == nil {
t.Error("UpdateInstance with missing set: want error")
}
}
// TestListSkipsVersionedFiles checks List returns only current revisions and
// reflects metadata after updates.
func TestListSkipsVersionedFiles(t *testing.T) {
s, _ := New(t.TempDir())
a, _ := s.CreateSet(sampleSet(), "")
b, _ := s.CreateSet(sampleSet(), "")
// Create backups for a so the dir holds versioned files too.
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
metas, err := s.List(KindSet)
if err != nil {
t.Fatal(err)
}
if len(metas) != 2 {
t.Fatalf("List: want 2 current sets, got %d", len(metas))
}
ids := map[string]bool{}
for _, m := range metas {
ids[m.ID] = true
}
if !ids[a.ID] || !ids[b.ID] {
t.Errorf("List missing expected ids: %v", ids)
}
}
+20
View File
@@ -95,6 +95,26 @@ func TestRuleTransformPersists(t *testing.T) {
}
}
func TestDisabledRuleSkipped(t *testing.T) {
s, _ := New(t.TempDir())
set, _ := s.CreateSet(sampleSet(), "")
disabled := false
if _, err := s.CreateRule(ConfigRule{
Name: "voltage cap",
SetID: set.ID,
Enabled: &disabled,
Source: "voltage: <=24",
}, ""); err != nil {
t.Fatal(err)
}
// A value that would violate the rule still saves because the rule is off.
if _, err := s.CreateInstance(ConfigInstance{
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
}, ""); err != nil {
t.Fatalf("expected disabled rule to be skipped, got %v", err)
}
}
func asRuleError(err error, target **RuleError) bool {
for err != nil {
if re, ok := err.(*RuleError); ok {
+6 -5
View File
@@ -31,6 +31,7 @@ func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, er
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) {
@@ -102,7 +103,7 @@ func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
func TestApplyConfigWritesEveryParam(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.applyConfig(cg, instID)
@@ -116,7 +117,7 @@ func TestApplyConfigWritesEveryParam(t *testing.T) {
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
e, src, _ := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.applyConfig(cg, "does-not-exist")
@@ -143,7 +144,7 @@ func TestReadConfigParam(t *testing.T) {
func TestWriteConfigParam(t *testing.T) {
e, _, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
e.writeConfigParam(cg, instID, "gain", 7.5)
@@ -162,7 +163,7 @@ func TestWriteConfigParam(t *testing.T) {
func TestCreateConfigInstance(t *testing.T) {
e, _, srcID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
src, err := e.cfg.GetInstance(srcID)
if err != nil {
@@ -196,7 +197,7 @@ func TestCreateConfigInstance(t *testing.T) {
func TestSnapshotConfig(t *testing.T) {
e, src, instID := setupConfigEngine(t)
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]Value{}}
// Seed current live values for the set's target signals.
_ = src.Write(context.Background(), "GAIN", 3.5)
+1 -1
View File
@@ -3,7 +3,7 @@
// Fields, in order: minute hour day-of-month month day-of-week.
// Each field supports:
//
// * any value
// - any value
// */n every n (step over the whole range)
// a-b inclusive range
// a-b/n range with step
+174
View File
@@ -0,0 +1,174 @@
package controllogic
import (
"context"
"sync"
"time"
"github.com/uopi/uopi/internal/broker"
)
// DebugEvent reports a single node execution for the live debug view. Value is
// only meaningful when HasValue is true (e.g. an action.write's written value or
// a flow.if branch as 0/1); otherwise the event just marks the node as active.
type DebugEvent struct {
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value any `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"` // unix millis
}
// DebugObserver receives node-execution events from running (and simulated)
// graphs. The server implements it; Observe must not block (the hub fans out
// drop-on-full so a slow editor never stalls the engine).
type DebugObserver interface {
Observe(DebugEvent)
}
// debugObsBox wraps a DebugObserver so atomic.Value always sees one type.
type debugObsBox struct{ o DebugObserver }
// SetDebugObserver installs the sink for node-execution events. Safe to call
// once at startup; read lock-free by running flows.
func (e *Engine) SetDebugObserver(o DebugObserver) {
e.debugObs.Store(debugObsBox{o: o})
}
// SetDebugWatch replaces the set of graph ids with at least one live debug
// subscriber. emitDebug short-circuits for graphs absent from this set, so the
// common (nobody watching) case costs a single atomic load. The hub owns the
// map and must not mutate it after publishing (it is read without a lock).
func (e *Engine) SetDebugWatch(ids map[string]bool) {
e.debugWatch.Store(ids)
}
// registerFire records a compiled graph's manual-fire channel under its route id
// (live graph id or simulate sandbox id).
func (e *Engine) registerFire(id string, ch chan string) {
e.fireMu.Lock()
e.fireChs[id] = ch
e.fireMu.Unlock()
}
// unregisterFire removes id's fire channel, but only if it still points at ch —
// so a newer generation that reused the same id (live reload) is not clobbered
// by the old generation's teardown.
func (e *Engine) unregisterFire(id string, ch chan string) {
e.fireMu.Lock()
if e.fireChs[id] == ch {
delete(e.fireChs, id)
}
e.fireMu.Unlock()
}
// FireTrigger asks the graph behind a debug route (graphID) to run triggerID's
// flow now, as if the trigger had fired. Non-blocking and best-effort: returns
// false if the route is gone or its fire buffer is full. The receiving graph
// validates that triggerID is actually one of its trigger nodes.
func (e *Engine) FireTrigger(graphID, triggerID string) bool {
e.fireMu.Lock()
ch := e.fireChs[graphID]
e.fireMu.Unlock()
if ch == nil || triggerID == "" {
return false
}
select {
case ch <- triggerID:
return true
default:
return false
}
}
// emitDebug reports a node execution to the observer when the graph is watched
// (or the graph is a simulate sandbox, which is always its own subscriber).
func (cg *compiledGraph) emitDebug(nodeID string, value Value, hasValue bool) {
if !cg.alwaysDebug {
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
if !w[cg.id] {
return
}
}
box, _ := cg.engine.debugObs.Load().(debugObsBox)
if box.o == nil {
return
}
box.o.Observe(DebugEvent{
GraphID: cg.id,
NodeID: nodeID,
Value: value,
HasValue: hasValue,
TS: time.Now().UnixMilli(),
})
}
// StartSimulate runs g in a throwaway sandbox generation: real side effects are
// suppressed (data-source writes, config mutations, dialogs) but local vars,
// triggers, timers and the flow itself execute normally, emitting debug events
// the editor can visualise. It is independent of Reload's live generation. The
// returned stop func cancels the sandbox and waits for its goroutines to drain;
// it is safe to call more than once.
func (e *Engine) StartSimulate(g Graph) func() {
cg := compile(g)
cg.engine = e
cg.dryRun = true
cg.alwaysDebug = true
ctx, cancel := context.WithCancel(e.root)
wg := &sync.WaitGroup{}
cg.genCtx = ctx
cg.wg = wg
// Sandbox-local live cache so simulate reads don't disturb the live engine.
updates := make(chan broker.Update, 128)
var unsubs []func()
for _, r := range cg.refs {
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
if err != nil {
e.log.Warn("control logic simulate: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
continue
}
unsubs = append(unsubs, unsub)
}
e.registerFire(cg.id, cg.fireCh)
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
for _, u := range unsubs {
u()
}
e.unregisterFire(cg.id, cg.fireCh)
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case u := <-updates:
val := toNum(u.Value.Data)
key := refKey(u.Ref.DS, u.Ref.Name)
e.liveMu.Lock()
e.live[key] = val
e.liveMu.Unlock()
cg.onSignal(key, val)
case <-ctx.Done():
return
}
}
}()
cg.startTriggers()
var once sync.Once
return func() {
once.Do(func() {
cancel()
wg.Wait()
})
}
}
+196
View File
@@ -0,0 +1,196 @@
package controllogic
import (
"context"
"sync"
"testing"
"time"
)
// fakeObserver records every DebugEvent it receives, in order.
type fakeObserver struct {
mu sync.Mutex
events []DebugEvent
}
func (f *fakeObserver) Observe(ev DebugEvent) {
f.mu.Lock()
f.events = append(f.events, ev)
f.mu.Unlock()
}
func (f *fakeObserver) snapshot() []DebugEvent {
f.mu.Lock()
defer f.mu.Unlock()
return append([]DebugEvent(nil), f.events...)
}
func (f *fakeObserver) last(nodeID string) (DebugEvent, bool) {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.events) - 1; i >= 0; i-- {
if f.events[i].NodeID == nodeID {
return f.events[i], true
}
}
return DebugEvent{}, false
}
// thresholdWriteGraph is a 2-node flow: a timer trigger → an action.write of 42
// to "tgt:OUT". Used by both the observe and simulate tests.
func thresholdWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "100"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestDebugObserverCapturesNodeValues drives a flow directly and asserts the
// observer saw the trigger fire and the write node's value — and that the real
// data-source write still happened (live mode, not dry-run).
func TestDebugObserverCapturesNodeValues(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{"g1": true})
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
events := obs.snapshot()
if len(events) == 0 {
t.Fatal("observer captured no events")
}
// Trigger then write must both be reported.
if _, ok := obs.last("t"); !ok {
t.Error("no event for trigger node 't'")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "g1" {
t.Errorf("event GraphID = %q, want g1", wEv.GraphID)
}
// Live mode: the real write went through.
if got, ok := src.get("OUT"); !ok || toNum(got) != 42 {
t.Errorf("OUT = %v (ok=%v), want 42 written", got, ok)
}
}
// manualWriteGraph is a flow whose trigger never fires on its own (a threshold
// with no signal wired) → an action.write of 42 to "tgt:OUT". Used to prove
// FireTrigger forces a run that would otherwise never happen.
func manualWriteGraph(id string) Graph {
return Graph{
ID: id,
Name: "flow",
Nodes: []Node{
{ID: "t", Kind: "trigger.threshold", Params: map[string]string{"op": ">", "value": "0"}},
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
},
Wires: []Wire{{From: "t", To: "w"}},
}
}
// TestFireTriggerForcesRun verifies FireTrigger drives a flow whose trigger
// never fires on its own, routed through the simulate sandbox.
func TestFireTriggerForcesRun(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(manualWriteGraph("sim"))
defer stop()
// Nothing should have run yet — the threshold trigger has no signal.
time.Sleep(50 * time.Millisecond)
if _, ok := obs.last("w"); ok {
t.Fatal("write node ran before any manual fire")
}
if !e.FireTrigger("sim", "t") {
t.Fatal("FireTrigger returned false for an active simulate route")
}
// Poll for the write node event (the flow runs on its own goroutine).
var wEv DebugEvent
for i := 0; i < 100; i++ {
if ev, ok := obs.last("w"); ok {
wEv = ev
break
}
time.Sleep(10 * time.Millisecond)
}
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
}
// An unknown route id must be a no-op (best-effort, returns false).
if e.FireTrigger("does-not-exist", "t") {
t.Error("FireTrigger returned true for an unknown route")
}
}
// TestDebugWatchGatesEmission verifies emitDebug is silent for graphs absent
// from the watch set, so unwatched live graphs cost nothing.
func TestDebugWatchGatesEmission(t *testing.T) {
e, _, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
e.SetDebugWatch(map[string]bool{}) // nobody watching
cg := compile(thresholdWriteGraph("g1"))
cg.engine = e
cg.genCtx = context.Background()
cg.wg = &sync.WaitGroup{}
cg.activate("t")
cg.wg.Wait()
if got := obs.snapshot(); len(got) != 0 {
t.Errorf("observer received %d events for an unwatched graph, want 0", len(got))
}
}
// TestSimulateSuppressesWrites runs the graph through the dry-run sandbox and
// asserts node events are still emitted (alwaysDebug) but NO real write occurs.
func TestSimulateSuppressesWrites(t *testing.T) {
e, src, _ := setupConfigEngine(t)
obs := &fakeObserver{}
e.SetDebugObserver(obs)
// Deliberately leave the watch set empty: simulate must emit regardless.
e.SetDebugWatch(map[string]bool{})
stop := e.StartSimulate(thresholdWriteGraph("sim"))
time.Sleep(250 * time.Millisecond) // let the 100 ms timer fire at least once
stop()
if _, ok := src.get("OUT"); ok {
t.Errorf("simulate performed a real write to OUT, want none")
}
wEv, ok := obs.last("w")
if !ok {
t.Fatal("simulate produced no event for write node 'w'")
}
if !wEv.HasValue || wEv.Value != 42.0 {
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
}
if wEv.GraphID != "sim" {
t.Errorf("simulate event GraphID = %q, want sim", wEv.GraphID)
}
}
+229 -12
View File
@@ -60,6 +60,20 @@ type Engine struct {
// holds while it waits for those same goroutines to drain).
notifier atomic.Value // notifierBox
// debugObs receives per-node execution events for the live debug view, and
// debugWatch is the set of graph ids with at least one live subscriber. Both
// are read lock-free by flow goroutines (same trick as notifier); emitDebug
// short-circuits cheaply when the firing graph has no watcher.
debugObs atomic.Value // debugObsBox
debugWatch atomic.Value // map[string]bool (immutable snapshot)
// fireChs maps a debug route id (live graph id or simulate sandbox id) to its
// compiled graph's manual-fire channel, so the debug UI can force a trigger to
// run. Entries are added/removed as generations (and simulate sandboxes) come
// and go; FireTrigger does a non-blocking send so a torn-down graph drops.
fireMu sync.Mutex
fireChs map[string]chan string
// Shared live signal cache for the current generation (key "ds\0name").
liveMu sync.RWMutex
live map[string]float64
@@ -91,6 +105,7 @@ func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *conf
log: log,
root: root,
live: map[string]float64{},
fireChs: map[string]chan string{},
}
}
@@ -187,6 +202,7 @@ func (e *Engine) Reload() {
cg.engine = e
cg.genCtx = genCtx
cg.wg = wg
e.registerFire(cg.id, cg.fireCh)
}
// One shared updates channel feeds a single dispatch goroutine; every
@@ -209,6 +225,9 @@ func (e *Engine) Reload() {
for _, u := range unsubs {
u()
}
for _, cg := range compiled {
e.unregisterFire(cg.id, cg.fireCh)
}
}()
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
@@ -257,17 +276,54 @@ func (e *Engine) liveGet(ds, name string) float64 {
return v
}
// setPath assigns v at the nested index path within arr, growing (zero-filling)
// as needed and resolving negative indices against length. Adapted from logic.ts;
// unlike the single-threaded TS source it copies on descent rather than mutating in
// place, because a graph-local slice (and its nested sub-slices) may be read
// concurrently by other flow goroutines — in-place mutation of shared backing would
// race. Every level returns a freshly allocated slice.
func setPath(arr []Value, path []int, v Value) []Value {
if len(path) == 0 {
return arr
}
k := path[0]
if k < 0 {
k = len(arr) + k
}
if k < 0 {
return arr
}
out := append([]Value{}, arr...) // copy: backing may be shared with the live local
for len(out) <= k {
out = append(out, 0.0)
}
if len(path) == 1 {
out[k] = v
return out
}
sub, _ := out[k].([]Value)
out[k] = setPath(sub, path[1:], v)
return out
}
// write applies an action.write/lua-set to a target: a bare name updates a
// graph-local var; a ds:name target writes to the data source.
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
func (e *Engine) write(cg *compiledGraph, target string, val Value) {
ds, name, ok := parseRef(target)
if !ok || math.IsNaN(val) {
if !ok {
return
}
if ds == "local" {
cg.setLocal(name, val)
return
}
f, isNum := val.(float64)
if !isNum || math.IsNaN(f) {
return
}
if cg.dryRun {
return // simulate: no real data-source write
}
src, ok := e.broker.Source(ds)
if !ok {
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
@@ -279,11 +335,11 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
Action: "signal.write",
DS: ds,
Signal: name,
Value: strconv.FormatFloat(val, 'g', -1, 64),
Value: strconv.FormatFloat(f, 'g', -1, 64),
Detail: "control logic: " + cg.name,
Outcome: audit.OutcomeOK,
}
if err := src.Write(e.root, name, val); err != nil {
if err := src.Write(e.root, name, f); err != nil {
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
ev.Outcome = audit.OutcomeError
ev.Error = err.Error()
@@ -558,6 +614,14 @@ type compiledGraph struct {
genCtx context.Context
wg *sync.WaitGroup
// dryRun suppresses real side effects (data-source writes, config mutations,
// dialogs) — used by the debug "simulate" sandbox. Local-variable writes stay
// live so the flow still computes correctly. alwaysDebug forces emitDebug to
// fire regardless of debugWatch (the sandbox is its own subscriber).
dryRun bool
alwaysDebug bool
id string
name string
byId map[string]Node
out map[string][]wireOut
@@ -567,17 +631,24 @@ type compiledGraph struct {
watchers map[string][]string // signal key → trigger node ids
luaNodes map[string]*luaRuntime
// fireCh receives node ids of triggers the debug UI wants to fire manually.
// Drained by a generation goroutine (started in startTriggers) so the wg.Add
// in activate always happens on a tracked goroutine.
fireCh chan string
stateMu sync.Mutex
levelState map[string]bool // current truth of level triggers (threshold/alarm)
prevBool map[string]bool // edge detection for threshold/alarm
prevVal map[string]float64 // last value for change triggers
hasVal map[string]bool
lastFire map[string]int64 // ns wall clock each trigger last fired
locals map[string]float64
locals map[string]Value
decls map[string]StateVar
}
func compile(g Graph) *compiledGraph {
cg := &compiledGraph{
id: g.ID,
name: g.Name,
byId: map[string]Node{},
out: map[string][]wireOut{},
@@ -590,11 +661,21 @@ func compile(g Graph) *compiledGraph {
prevVal: map[string]float64{},
hasVal: map[string]bool{},
lastFire: map[string]int64{},
locals: map[string]float64{},
locals: map[string]Value{},
decls: map[string]StateVar{},
fireCh: make(chan string, 16),
}
for _, n := range g.Nodes {
cg.byId[n.ID] = n
}
for _, sv := range g.StateVars {
cg.decls[sv.Name] = sv
if sv.Type == "array" {
cg.locals[sv.Name] = applySizing(parseInitialArray(sv), sv)
} else {
cg.locals[sv.Name] = parseScalarInitial(sv)
}
}
for _, w := range g.Wires {
port := w.FromPort
if port == "" {
@@ -635,6 +716,13 @@ func compile(g Graph) *compiledGraph {
}
case "action.write", "action.log":
wantExpr(n.param("expr"))
case "action.array.push":
wantExpr(n.param("expr"))
case "action.array.set":
wantExpr(n.param("expr"))
wantExpr(n.param("index"))
case "action.array.remove":
wantExpr(n.param("index"))
case "action.lua":
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
for _, r := range luaGetRefs(n.param("script")) {
@@ -645,24 +733,62 @@ func compile(g Graph) *compiledGraph {
return cg
}
func (cg *compiledGraph) setLocal(name string, v float64) {
func parseScalarInitial(sv StateVar) float64 {
s := strings.TrimSpace(sv.Initial)
switch s {
case "true":
return 1
case "false":
return 0
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
return f
}
func (cg *compiledGraph) setLocal(name string, v Value) {
cg.stateMu.Lock()
if sv, ok := cg.decls[name]; ok && sv.Type == "array" {
if arr, isArr := v.([]Value); isArr {
v = applySizing(arr, sv)
}
}
cg.locals[name] = v
cg.stateMu.Unlock()
}
func (cg *compiledGraph) getLocal(name string) float64 {
func (cg *compiledGraph) getLocal(name string) Value {
cg.stateMu.Lock()
defer cg.stateMu.Unlock()
v, ok := cg.locals[name]
if !ok {
return 0
return 0.0
}
return v
}
// startTriggers launches timer and cron trigger goroutines for the generation.
func (cg *compiledGraph) startTriggers() {
// Manual-fire listener: drain fireCh on a tracked goroutine so activate's
// wg.Add never races the generation teardown's wg.Wait. Only fires nodes that
// are actual triggers in this graph.
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
for {
select {
case id := <-cg.fireCh:
if n, ok := cg.byId[id]; ok && strings.HasPrefix(n.Kind, "trigger.") {
cg.activate(id)
}
case <-cg.genCtx.Done():
return
}
}
}()
hasCron := false
for _, n := range cg.byId {
switch n.Kind {
@@ -842,7 +968,7 @@ func (cg *compiledGraph) activate(triggerID string) {
dt = float64(now-last) / 1e9
}
resolve := func(ds, name string) float64 {
resolve := func(ds, name string) Value {
switch ds {
case "sys":
if name == "dt" {
@@ -856,6 +982,8 @@ func (cg *compiledGraph) activate(triggerID string) {
}
}
cg.emitDebug(triggerID, 0, false)
cg.wg.Add(1)
go func() {
defer cg.wg.Done()
@@ -888,6 +1016,8 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
default:
}
cg.emitDebug(node.ID, 0, false)
switch node.Kind {
case "gate.and":
if cg.gateSatisfied(node.ID, ctx.fired) {
@@ -896,9 +1026,15 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "flow.if":
branch := "else"
if EvalBool(node.param("cond"), ctx.resolve) {
pass := EvalBool(node.param("cond"), ctx.resolve)
if pass {
branch = "then"
}
v := 0.0
if pass {
v = 1
}
cg.emitDebug(node.ID, v, true)
cg.follow(node.ID, branch, ctx)
case "flow.loop":
@@ -921,12 +1057,15 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.follow(node.ID, "done", ctx)
case "action.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
val := EvalValue(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
cg.engine.write(cg, node.param("target"), val)
cg.follow(node.ID, "out", ctx)
case "action.config.apply":
if !cg.dryRun {
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
}
cg.follow(node.ID, "out", ctx)
case "action.config.read":
@@ -938,15 +1077,22 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "action.config.write":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
if !cg.dryRun {
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":
if !cg.dryRun {
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":
if !cg.dryRun {
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
}
cg.follow(node.ID, "out", ctx)
case "action.delay":
@@ -967,6 +1113,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
case "action.log":
val := EvalExpr(node.param("expr"), ctx.resolve)
cg.emitDebug(node.ID, val, true)
label := strings.TrimSpace(node.param("label"))
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
cg.follow(node.ID, "out", ctx)
@@ -976,7 +1123,77 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
cg.follow(node.ID, "out", ctx)
case "action.dialog":
if !cg.dryRun {
cg.engine.emitDialog(node)
}
cg.follow(node.ID, "out", ctx)
case "action.array.push":
name := strings.TrimSpace(node.param("array"))
if name != "" {
val := EvalValue(node.param("expr"), ctx.resolve)
cur, _ := cg.getLocal(name).([]Value)
cg.setLocal(name, append(append([]Value{}, cur...), val))
}
cg.follow(node.ID, "out", ctx)
case "action.array.set":
name := strings.TrimSpace(node.param("array"))
if name != "" {
cur, _ := cg.getLocal(name).([]Value)
arr := append([]Value{}, cur...)
var path []int
ok := true
for _, s := range strings.Split(node.param("index"), ",") {
f := EvalExpr(strings.TrimSpace(s), ctx.resolve)
if math.IsNaN(f) {
ok = false
break
}
path = append(path, int(f))
}
val := EvalValue(node.param("expr"), ctx.resolve)
if ok && len(path) > 0 {
arr = setPath(arr, path, val)
}
cg.setLocal(name, arr)
}
cg.follow(node.ID, "out", ctx)
case "action.array.remove":
name := strings.TrimSpace(node.param("array"))
if name != "" {
cur, _ := cg.getLocal(name).([]Value)
arr := append([]Value{}, cur...)
i := int(EvalExpr(node.param("index"), ctx.resolve))
k := i
if k < 0 {
k = len(arr) + k
}
if k >= 0 && k < len(arr) {
arr = append(arr[:k], arr[k+1:]...)
}
cg.setLocal(name, arr)
}
cg.follow(node.ID, "out", ctx)
case "action.array.pop":
name := strings.TrimSpace(node.param("array"))
if name != "" {
cur, _ := cg.getLocal(name).([]Value)
arr := append([]Value{}, cur...)
if len(arr) > 0 {
arr = arr[:len(arr)-1]
}
cg.setLocal(name, arr)
}
cg.follow(node.ID, "out", ctx)
case "action.array.clear":
name := strings.TrimSpace(node.param("array"))
if name != "" {
cg.setLocal(name, []Value{}) // setLocal applies sizing (fixed → zero-pad)
}
cg.follow(node.ID, "out", ctx)
default:
@@ -0,0 +1,93 @@
package controllogic
import (
"math"
"testing"
"github.com/uopi/uopi/internal/confmgr"
)
func TestFormatAny(t *testing.T) {
cases := []struct {
in any
want string
}{
{3.5, "3.5"},
{float64(42), "42"},
{"hello", "hello"},
{true, "true"},
{int64(7), "7"},
}
for _, c := range cases {
if got := formatAny(c.in); got != c.want {
t.Errorf("formatAny(%v) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTestThreshold(t *testing.T) {
cases := []struct {
val float64
op string
cmp float64
want bool
}{
{1, "<", 2, true},
{3, "<", 2, false},
{2, ">=", 2, true},
{1, ">=", 2, false},
{2, "<=", 2, true},
{3, "<=", 2, false},
{2, "==", 2, true},
{2, "!=", 3, true},
{5, ">", 2, true}, // default branch
{1, "", 0, true}, // empty op → ">"
{math.NaN(), "<", 1, false}, // NaN never satisfies
}
for _, c := range cases {
if got := testThreshold(c.val, c.op, c.cmp); got != c.want {
t.Errorf("testThreshold(%v,%q,%v) = %v, want %v", c.val, c.op, c.cmp, got, c.want)
}
}
}
func TestParseFloat(t *testing.T) {
cases := []struct {
in string
want float64
}{
{"3.14", 3.14},
{" 10 ", 10},
{"", 0},
{"not-a-number", 0},
}
for _, c := range cases {
if got := parseFloat(c.in); got != c.want {
t.Errorf("parseFloat(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestCoerceParamValue(t *testing.T) {
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeInt}, 3.9); v != int64(3) {
t.Errorf("int coerce = %v, want 3", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 0); v != false {
t.Errorf("bool 0 = %v, want false", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 1); v != true {
t.Errorf("bool 1 = %v, want true", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeString}, 2.5); v != "2.5" {
t.Errorf("string coerce = %v, want \"2.5\"", v)
}
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeFloat}, 1.25); v != 1.25 {
t.Errorf("float coerce = %v, want 1.25", v)
}
}
func TestSign(t *testing.T) {
if signOf(5) != 1 || signOf(-5) != -1 || signOf(0) != 0 {
t.Errorf("sign mismatch: %d %d %d", signOf(5), signOf(-5), signOf(0))
}
}
+180
View File
@@ -0,0 +1,180 @@
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/datasource"
)
// pushSource is a DataSource whose Subscribe captures the broker's delivery
// channel per signal, letting a test push successive values to drive level/edge
// triggers. Writes are recorded so action.write effects can be asserted.
type pushSource struct {
mu sync.Mutex
chans map[string]chan<- datasource.Value
written map[string]any
}
func newPushSource() *pushSource {
return &pushSource{chans: map[string]chan<- datasource.Value{}, written: map[string]any{}}
}
func (s *pushSource) Name() string { return "tgt" }
func (s *pushSource) Connect(context.Context) error { return nil }
func (s *pushSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
return nil, nil
}
func (s *pushSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
return datasource.Metadata{Name: sig, Writable: true}, nil
}
func (s *pushSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
s.mu.Lock()
s.chans[sig] = ch
s.mu.Unlock()
return func() {}, nil
}
func (s *pushSource) Write(_ context.Context, signal string, value any) error {
s.mu.Lock()
s.written[signal] = value
s.mu.Unlock()
return nil
}
func (s *pushSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
func (s *pushSource) chanFor(sig string) (chan<- datasource.Value, bool) {
s.mu.Lock()
defer s.mu.Unlock()
ch, ok := s.chans[sig]
return ch, ok
}
func (s *pushSource) get(sig string) (any, bool) {
s.mu.Lock()
defer s.mu.Unlock()
v, ok := s.written[sig]
return v, ok
}
// TestEngineReloadThresholdTrigger drives a real Reload generation: a
// trigger.threshold on tgt:IN (>5) wired to an action.write of 42 to tgt:OUT.
// Pushing IN below then above the threshold must fire exactly the rising edge.
func TestEngineReloadThresholdTrigger(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "watchdog",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.threshold", Params: map[string]string{
"signal": "tgt:IN", "op": ">", "value": "5",
}},
{ID: "a1", Kind: "action.write", Params: map[string]string{
"target": "tgt:OUT", "expr": "42",
}},
},
Wires: []Wire{{From: "t1", To: "a1"}},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
// t.Context() is cancelled at test cleanup, tearing the generation down.
// Wait until the engine has subscribed to tgt:IN.
var ch chan<- datasource.Value
waitFor(t, time.Second, func() bool {
c, ok := src.chanFor("IN")
if ok {
ch = c
}
return ok
})
// Below threshold: no fire (prev state seeds to false).
ch <- datasource.Value{Data: 0.0, Timestamp: time.Now()}
// Rising edge above threshold: fires the action.
ch <- datasource.Value{Data: 10.0, Timestamp: time.Now()}
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT")
return ok && toNum(v) == 42
})
if v, ok := src.get("OUT"); !ok || toNum(v) != 42 {
t.Fatalf("OUT = %v (ok=%v), want 42 after rising-edge trigger", v, ok)
}
}
// TestEngineReloadTimerIfWrite covers the timer trigger, startTriggers, and a
// flow.if then-branch driving an action.write.
func TestEngineReloadTimerIfWrite(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
ctx := t.Context()
src := newPushSource()
brk := broker.New(ctx, log)
brk.Register(src)
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal("NewStore:", err)
}
g := Graph{
Name: "ticker",
Enabled: true,
Nodes: []Node{
{ID: "t1", Kind: "trigger.timer", Params: map[string]string{"interval": "50"}},
{ID: "if1", Kind: "flow.if", Params: map[string]string{"cond": "2 > 1"}},
{ID: "a1", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT2", "expr": "7"}},
},
Wires: []Wire{
{From: "t1", To: "if1"},
{From: "if1", FromPort: "then", To: "a1"},
},
}
if err := store.Save(g); err != nil {
t.Fatal("Save:", err)
}
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
e.Reload()
waitFor(t, 2*time.Second, func() bool {
v, ok := src.get("OUT2")
return ok && toNum(v) == 7
})
}
// waitFor polls cond until it returns true or the deadline elapses.
func waitFor(t *testing.T, d time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
if !cond() {
t.Fatalf("condition not met within %s", d)
}
}
+242
View File
@@ -0,0 +1,242 @@
package controllogic
import (
"context"
"reflect"
"sync"
"testing"
)
// runFlowOnce compiles g, wires a minimal engine/context onto the compiled graph
// (enough for the run/follow path and the lock-free emitDebug guard), then drives
// the flow from triggerID synchronously and returns the compiled graph so callers
// can inspect locals.
func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph {
t.Helper()
cg := compile(g)
cg.engine = &Engine{}
cg.genCtx = context.Background()
R := func(ds, name string) Value {
switch ds {
case "local":
return cg.getLocal(name)
default:
return 0.0
}
}
ctx := &runCtx{fired: triggerID, resolve: R}
cg.follow(triggerID, "out", ctx)
return cg
}
func TestArrayPushNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "p", Kind: "action.array.push", Params: map[string]string{"array": "buf", "expr": "5"}},
},
Wires: []Wire{{From: "t", To: "p"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 5.0}) {
t.Fatalf("after push buf = %#v", got)
}
}
func TestArrayClearNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "fixed", Capacity: 3}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "c", Kind: "action.array.clear", Params: map[string]string{"array": "buf"}},
},
Wires: []Wire{{From: "t", To: "c"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) {
t.Fatalf("after clear buf = %#v", got)
}
}
func TestArraySetNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[0,0,0]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "buf", "index": "1", "expr": "9"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 9.0, 0.0}) {
t.Fatalf("after set buf = %#v", got)
}
}
func TestArrayRemoveNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[10,20,30]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "r", Kind: "action.array.remove", Params: map[string]string{"array": "buf", "index": "-1"}},
},
Wires: []Wire{{From: "t", To: "r"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{10.0, 20.0}) {
t.Fatalf("after remove buf = %#v", got)
}
}
func TestArrayPopNode(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "p", Kind: "action.array.pop", Params: map[string]string{"array": "buf"}},
},
Wires: []Wire{{From: "t", To: "p"}},
}
cg := runFlowOnce(t, g, "t")
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0}) {
t.Fatalf("after pop buf = %#v", got)
}
}
// TestArraySetNodeNested verifies set with a comma-separated path mutates a nested
// sub-array, and (crucially) that setPath does not mutate the previously stored slice
// in place — the stored value must be replaced by a fresh tree.
func TestArraySetNodeNested(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[1,2],[3,4]]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,1", "expr": "9"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := compile(g)
before := cg.getLocal("grid")
cg.engine = &Engine{}
cg.genCtx = context.Background()
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
cg.follow("t", "out", &runCtx{fired: "t", resolve: R})
want := []Value{[]Value{1.0, 9.0}, []Value{3.0, 4.0}}
if got := cg.getLocal("grid"); !reflect.DeepEqual(got, want) {
t.Fatalf("after nested set grid = %#v", got)
}
// The pre-mutation snapshot must be untouched (no shared-backing in-place write).
if !reflect.DeepEqual(before, []Value{[]Value{1.0, 2.0}, []Value{3.0, 4.0}}) {
t.Fatalf("setPath mutated the prior stored value in place: %#v", before)
}
}
// TestArraySetNodeConcurrentNoRace drives many concurrent set+read flows against the
// same nested-array local; with -race it guards the setPath copy-on-descend fix.
func TestArraySetNodeConcurrentNoRace(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[0,0],[0,0]]", Sizing: "dynamic"}},
Nodes: []Node{
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,0", "expr": "1"}},
},
Wires: []Wire{{From: "t", To: "s"}},
}
cg := compile(g)
cg.engine = &Engine{}
cg.genCtx = context.Background()
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(2)
go func() { defer wg.Done(); cg.follow("t", "out", &runCtx{fired: "t", resolve: R}) }()
go func() {
defer wg.Done()
// Read the inner element concurrently; with the in-place setPath bug the
// writer mutates this same sub-array backing, producing a data race.
if arr, ok := cg.getLocal("grid").([]Value); ok && len(arr) > 0 {
if sub, ok := arr[0].([]Value); ok && len(sub) > 0 {
_ = sub[0]
}
}
}()
}
wg.Wait()
}
func TestCompileInitsLocalsFromDecls(t *testing.T) {
g := Graph{
ID: "g", Name: "n",
StateVars: []StateVar{
{Name: "count", Type: "number", Initial: "7"},
{Name: "flag", Type: "bool", Initial: "true"},
{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4},
},
}
cg := compile(g)
if got := cg.getLocal("count"); got != Value(7.0) {
t.Fatalf("count = %#v", got)
}
if got := cg.getLocal("flag"); got != Value(1.0) {
t.Fatalf("flag = %#v", got)
}
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
t.Fatalf("buf = %#v", got)
}
}
func TestSetLocalAppliesSizing(t *testing.T) {
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
{Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2},
}}
cg := compile(g)
cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0})
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) {
t.Fatalf("sized buf = %#v", got)
}
}
func TestResolverReturnsLocalValue(t *testing.T) {
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
{Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"},
}}
cg := compile(g)
R := func(ds, name string) Value {
if ds == "local" {
return cg.getLocal(name)
}
return 0.0
}
if v := EvalExpr("buf[1]", R); v != 20 {
t.Fatalf("buf[1] = %v", v)
}
if v := EvalExpr("sum(buf)", R); v != 30 {
t.Fatalf("sum(buf) = %v", v)
}
}
func TestEmitDebugAcceptsArray(t *testing.T) {
g := Graph{ID: "g", Name: "n"}
cg := compile(g)
cg.engine = &Engine{} // no observer installed; emitDebug must not panic
cg.emitDebug("x", []Value{1.0, 2.0}, true)
cg.emitDebug("x", 3.0, true)
}
+360 -54
View File
@@ -2,16 +2,15 @@
//
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
// parentheses, and a handful of math functions. Two kinds of variable
// reference are resolved live at evaluation time:
// parentheses, array literals ([a, b, c]), postfix indexing (arr[i]), and a set
// of math + array functions. Two kinds of variable reference are resolved live:
//
// {ds:name} a data-source signal value (the brace content is split on the
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
// {ds:name} a data-source signal value (brace content split on FIRST ':').
// bareIdent a graph-local state variable (data source "local").
//
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
// and any nonzero value is truthy. The evaluator never uses reflection or eval;
// it walks a parsed AST against a caller-supplied Resolver.
// Values are either a scalar (float64; booleans 1/0) or an array ([]Value). The
// evaluator never uses reflection or eval; it walks a parsed AST against a
// caller-supplied Resolver.
package controllogic
import (
@@ -22,8 +21,8 @@ import (
"sync"
)
// Resolver returns the current numeric value of a signal/local reference.
type Resolver func(ds, name string) float64
// Resolver returns the current value of a signal/local reference.
type Resolver func(ds, name string) Value
// RefLite identifies one signal/local reference read by an expression.
type RefLite struct {
@@ -33,11 +32,13 @@ type RefLite struct {
// ── AST ──────────────────────────────────────────────────────────────────────
type exprNode interface{ eval(R Resolver) float64 }
type exprNode interface{ eval(R Resolver) Value }
type numNode struct{ v float64 }
type sigNode struct{ ds, name string }
type varNode struct{ name string }
type arrNode struct{ items []exprNode }
type indexNode struct{ a, i exprNode }
type unNode struct {
op string
a exprNode
@@ -52,33 +53,84 @@ type callNode struct {
args []exprNode
}
func (n numNode) eval(R Resolver) float64 { return n.v }
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
func (n unNode) eval(R Resolver) float64 {
func mustNum(v Value) float64 {
f, err := asNum(v)
if err != nil {
panic(err)
}
return f
}
func mustArr(v Value) []Value {
a, err := asArr(v)
if err != nil {
panic(err)
}
return a
}
func (n numNode) eval(R Resolver) Value { return n.v }
func (n sigNode) eval(R Resolver) Value { return R(n.ds, n.name) }
func (n varNode) eval(R Resolver) Value { return R("local", n.name) }
func (n arrNode) eval(R Resolver) Value {
out := make([]Value, len(n.items))
for i, it := range n.items {
out[i] = it.eval(R)
}
return out
}
func (n indexNode) eval(R Resolver) Value {
arr := mustArr(n.a.eval(R))
k, err := idxResolve(mustNum(n.i.eval(R)), len(arr))
if err != nil {
panic(err)
}
return arr[k]
}
func (n unNode) eval(R Resolver) Value {
if n.op == "-" {
return -n.a.eval(R)
return -mustNum(n.a.eval(R))
}
if n.a.eval(R) == 0 {
return 1
if mustNum(n.a.eval(R)) == 0 {
return 1.0
}
return 0
return 0.0
}
func (n ternNode) eval(R Resolver) float64 {
if n.c.eval(R) != 0 {
func (n ternNode) eval(R Resolver) Value {
if mustNum(n.c.eval(R)) != 0 {
return n.a.eval(R)
}
return n.b.eval(R)
}
func (n callNode) eval(R Resolver) float64 {
args := make([]float64, len(n.args))
func (n callNode) eval(R Resolver) Value {
args := make([]Value, len(n.args))
for i, a := range n.args {
args[i] = a.eval(R)
}
return funcs[n.fn](args)
// min/max: scalar-variadic OR single-array form.
if n.fn == "min" || n.fn == "max" {
if len(args) == 1 {
if arr, ok := args[0].([]Value); ok {
return reduceMinMax(n.fn, arr)
}
func (n binNode) eval(R Resolver) float64 {
a, b := n.a.eval(R), n.b.eval(R)
}
nums := make([]Value, len(args))
copy(nums, args)
return reduceMinMax(n.fn, nums)
}
if af, ok := arrFuncs[n.fn]; ok {
return af(args)
}
if sf, ok := scalarFuncs[n.fn]; ok {
nums := make([]float64, len(args))
for i, a := range args {
nums[i] = mustNum(a)
}
return sf(nums)
}
panic(fmt.Errorf("unknown function %q", n.fn))
}
func (n binNode) eval(R Resolver) Value {
a, b := mustNum(n.a.eval(R)), mustNum(n.b.eval(R))
switch n.op {
case "+":
return a + b
@@ -107,7 +159,7 @@ func (n binNode) eval(R Resolver) float64 {
case "||":
return boolf(a != 0 || b != 0)
}
return math.NaN()
panic(fmt.Errorf("unknown operator %q", n.op))
}
func boolf(b bool) float64 {
@@ -117,15 +169,34 @@ func boolf(b bool) float64 {
return 0
}
var funcs = map[string]func([]float64) float64{
func reduceMinMax(fn string, arr []Value) Value {
if len(arr) == 0 {
if fn == "min" {
return math.Inf(1)
}
return math.Inf(-1)
}
m := mustNum(arr[0])
for _, x := range arr[1:] {
v := mustNum(x)
if fn == "min" {
m = math.Min(m, v)
} else {
m = math.Max(m, v)
}
}
return m
}
// ── Functions ────────────────────────────────────────────────────────────────
var scalarFuncs = map[string]func([]float64) float64{
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
"min": func(a []float64) float64 { return minSlice(a) },
"max": func(a []float64) float64 { return maxSlice(a) },
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
"round": func(a []float64) float64 { return math.Round(a[0]) },
"sign": func(a []float64) float64 { return float64(sign(a[0])) },
"sign": func(a []float64) float64 { return float64(signOf(a[0])) },
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
"log": func(a []float64) float64 { return math.Log(a[0]) },
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
@@ -133,27 +204,145 @@ var funcs = map[string]func([]float64) float64{
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
}
func minSlice(a []float64) float64 {
if len(a) == 0 {
return math.Inf(1)
var arrFuncs = map[string]func([]Value) Value{
"len": func(a []Value) Value { return float64(len(mustArr(a[0]))) },
"sum": func(a []Value) Value {
s := 0.0
for _, x := range mustArr(a[0]) {
s += mustNum(x)
}
m := a[0]
for _, x := range a[1:] {
m = math.Min(m, x)
return s
},
"mean": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return 0.0
}
return m
s := 0.0
for _, x := range r {
s += mustNum(x)
}
func maxSlice(a []float64) float64 {
if len(a) == 0 {
return math.Inf(-1)
return s / float64(len(r))
},
"slice": func(a []Value) Value {
r := mustArr(a[0])
s := 0
e := len(r)
if len(a) > 1 {
s = clampIdx(int(mustNum(a[1])), len(r))
}
m := a[0]
for _, x := range a[1:] {
m = math.Max(m, x)
if len(a) > 2 {
e = clampIdx(int(mustNum(a[2])), len(r))
}
return m
if s > e {
s = e
}
func sign(x float64) int {
out := make([]Value, 0, e-s)
out = append(out, r[s:e]...)
return out
},
"concat": func(a []Value) Value { return append(append([]Value{}, mustArr(a[0])...), mustArr(a[1])...) },
"reverse": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...); reverse(r); return r },
"sort": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
sortNum(r)
return r
},
"scale": func(a []Value) Value {
r := mustArr(a[0])
k := mustNum(a[1])
out := make([]Value, len(r))
for i, x := range r {
out[i] = mustNum(x) * k
}
return out
},
"add": func(a []Value) Value {
return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x + y })
},
"sub": func(a []Value) Value {
return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x - y })
},
"push": func(a []Value) Value {
return append(append([]Value{}, mustArr(a[0])...), a[1])
},
"set": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k, err := idxResolve(mustNum(a[1]), len(r))
if err != nil {
panic(err)
}
r[k] = a[2]
return r
},
"insert": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k := int(mustNum(a[1]))
if k < 0 {
k = 0
}
if k > len(r) {
k = len(r)
}
r = append(r, nil)
copy(r[k+1:], r[k:])
r[k] = a[2]
return r
},
"remove": func(a []Value) Value {
r := append([]Value{}, mustArr(a[0])...)
k, err := idxResolve(mustNum(a[1]), len(r))
if err != nil {
panic(err)
}
return append(r[:k], r[k+1:]...)
},
"pop": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return []Value{}
}
return append([]Value{}, r[:len(r)-1]...)
},
"shift": func(a []Value) Value {
r := mustArr(a[0])
if len(r) == 0 {
return []Value{}
}
return append([]Value{}, r[1:]...)
},
"indexOf": func(a []Value) Value {
r := mustArr(a[0])
for i, x := range r {
if valEq(x, a[1]) {
return float64(i)
}
}
return -1.0
},
"contains": func(a []Value) Value {
r := mustArr(a[0])
for _, x := range r {
if valEq(x, a[1]) {
return 1.0
}
}
return 0.0
},
"fill": func(a []Value) Value {
n := int(mustNum(a[0]))
if n < 0 {
n = 0
}
out := make([]Value, n)
for i := range out {
out[i] = a[1]
}
return out
},
}
func signOf(x float64) int {
switch {
case x > 0:
return 1
@@ -164,6 +353,47 @@ func sign(x float64) int {
}
}
func clampIdx(i, length int) int {
if i < 0 {
i = length + i
}
if i < 0 {
i = 0
}
if i > length {
i = length
}
return i
}
func reverse(r []Value) {
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
}
func sortNum(r []Value) {
for i := 1; i < len(r); i++ {
for j := i; j > 0 && mustNum(r[j-1]) > mustNum(r[j]); j-- {
r[j-1], r[j] = r[j], r[j-1]
}
}
}
func zipNum(x, y []Value, f func(a, b float64) float64) []Value {
n := len(x)
if len(y) < n {
n = len(y)
}
out := make([]Value, 0, n)
for i := 0; i < n; i++ {
out = append(out, f(mustNum(x[i]), mustNum(y[i])))
}
return out
}
func valEq(a, b Value) bool {
af, aok := a.(float64)
bf, bok := b.(float64)
return aok && bok && af == bf
}
// ── Tokenizer ────────────────────────────────────────────────────────────────
type tok struct {
@@ -223,7 +453,7 @@ func tokenize(src string) ([]tok, error) {
continue
}
}
if strings.ContainsRune("+-*/%<>!()?:,", c) {
if strings.ContainsRune("+-*/%<>!()?:,[]", c) {
toks = append(toks, tok{k: string(c)})
i++
continue
@@ -279,7 +509,7 @@ func parse(src string) (exprNode, error) {
return root, nil
}
func (ps *parser) primary() (exprNode, error) {
func (ps *parser) atom() (exprNode, error) {
t, ok := ps.peek()
if !ok {
return nil, fmt.Errorf("unexpected end of expression")
@@ -292,6 +522,32 @@ func (ps *parser) primary() (exprNode, error) {
return nil, fmt.Errorf("bad number %q", t.v)
}
return numNode{v: v}, nil
case "[":
ps.eat("[")
var items []exprNode
if nx, ok := ps.peek(); ok && nx.k != "]" {
a, err := ps.ternary()
if err != nil {
return nil, err
}
items = append(items, a)
for {
nx2, ok := ps.peek()
if !ok || nx2.k != "," {
break
}
ps.eat(",")
a, err := ps.ternary()
if err != nil {
return nil, err
}
items = append(items, a)
}
}
if _, err := ps.eat("]"); err != nil {
return nil, err
}
return arrNode{items: items}, nil
case "sig":
ps.eat("")
idx := strings.IndexByte(t.v, ':')
@@ -333,7 +589,7 @@ func (ps *parser) primary() (exprNode, error) {
if _, err := ps.eat(")"); err != nil {
return nil, err
}
if _, ok := funcs[id]; !ok {
if !knownFunc(id) {
return nil, fmt.Errorf("unknown function %q", id)
}
return callNode{fn: id, args: args}, nil
@@ -353,6 +609,39 @@ func (ps *parser) primary() (exprNode, error) {
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
}
func knownFunc(id string) bool {
if id == "min" || id == "max" {
return true
}
if _, ok := arrFuncs[id]; ok {
return true
}
_, ok := scalarFuncs[id]
return ok
}
func (ps *parser) primary() (exprNode, error) {
n, err := ps.atom()
if err != nil {
return nil, err
}
for {
nx, ok := ps.peek()
if !ok || nx.k != "[" {
return n, nil
}
ps.eat("[")
i, err := ps.ternary()
if err != nil {
return nil, err
}
if _, err := ps.eat("]"); err != nil {
return nil, err
}
n = indexNode{a: n, i: i}
}
}
func (ps *parser) unary() (exprNode, error) {
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
ps.eat("")
@@ -449,8 +738,9 @@ func parseCached(src string) (exprNode, error) {
return n, err
}
// EvalExpr evaluates an expression string, returning NaN on parse/eval failure.
func EvalExpr(src string, resolve Resolver) float64 {
// EvalValue evaluates an expression, returning the full Value (number or array).
// Returns NaN on parse/eval failure.
func EvalValue(src string, resolve Resolver) Value {
n, err := parseCached(src)
if err != nil {
return math.NaN()
@@ -458,7 +748,7 @@ func EvalExpr(src string, resolve Resolver) float64 {
return safeEval(n, resolve)
}
func safeEval(n exprNode, resolve Resolver) (out float64) {
func safeEval(n exprNode, resolve Resolver) (out Value) {
defer func() {
if recover() != nil {
out = math.NaN()
@@ -467,14 +757,23 @@ func safeEval(n exprNode, resolve Resolver) (out float64) {
return n.eval(resolve)
}
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value.
// EvalExpr evaluates an expression to a scalar; returns NaN on parse/eval
// failure OR when the result is an array.
func EvalExpr(src string, resolve Resolver) float64 {
v := EvalValue(src, resolve)
if f, ok := v.(float64); ok {
return f
}
return math.NaN()
}
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN scalar.
func EvalBool(src string, resolve Resolver) bool {
v := EvalExpr(src, resolve)
return !math.IsNaN(v) && v != 0
}
// CollectRefs returns every signal/local reference an expression reads, for
// subscription. Returns nil for an unparseable expression.
// CollectRefs returns every signal/local reference an expression reads.
func CollectRefs(src string) []RefLite {
root, err := parseCached(src)
if err != nil {
@@ -496,6 +795,13 @@ func CollectRefs(src string) []RefLite {
add(t.ds, t.name)
case varNode:
add("local", t.name)
case arrNode:
for _, it := range t.items {
walk(it)
}
case indexNode:
walk(t.a)
walk(t.i)
case unNode:
walk(t.a)
case binNode:
+72 -2
View File
@@ -2,11 +2,12 @@ package controllogic
import (
"math"
"reflect"
"testing"
)
func TestEvalExpr(t *testing.T) {
resolve := func(ds, name string) float64 {
resolve := func(ds, name string) Value {
switch {
case ds == "stub" && name == "x":
return 10
@@ -45,7 +46,7 @@ func TestEvalExpr(t *testing.T) {
}
func TestEvalExprErrors(t *testing.T) {
r := func(ds, name string) float64 { return 0 }
r := func(ds, name string) Value { return 0 }
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
if v := EvalExpr(bad, r); !math.IsNaN(v) {
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
@@ -84,3 +85,72 @@ func TestEpicsRefSplitFirstColon(t *testing.T) {
t.Errorf("got %+v, want epics / SR:BPM:01:X", refs)
}
}
// ── New tests for value-polymorphic evaluator ─────────────────────────────────
func numResolver(vals map[string]Value) Resolver {
return func(ds, name string) Value {
if v, ok := vals[ds+":"+name]; ok {
return v
}
return math.NaN()
}
}
func TestEvalValueScalar(t *testing.T) {
R := numResolver(nil)
if got := EvalExpr("2 + 3 * 4", R); got != 14 {
t.Fatalf("scalar = %v", got)
}
if !EvalBool("1 < 2 && 3 >= 3", R) {
t.Fatal("bool expr should be true")
}
}
func TestEvalValueArrayLiteralAndIndex(t *testing.T) {
R := numResolver(nil)
got := EvalValue("[1, 2, 3]", R)
if !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
t.Fatalf("array literal = %#v", got)
}
if v := EvalExpr("[10,20,30][-1]", R); v != 30 {
t.Fatalf("index -1 = %v", v)
}
}
func TestEvalArrayFuncs(t *testing.T) {
R := numResolver(map[string]Value{"local:buf": []Value{3.0, 1.0, 2.0}})
if v := EvalExpr("len(buf)", R); v != 3 {
t.Fatalf("len = %v", v)
}
if v := EvalExpr("sum(buf)", R); v != 6 {
t.Fatalf("sum = %v", v)
}
if v := EvalExpr("max(buf)", R); v != 3 {
t.Fatalf("max(array) = %v", v)
}
if v := EvalExpr("max(1, 9, 4)", R); v != 9 {
t.Fatalf("max(scalars) = %v", v)
}
got := EvalValue("push(buf, 7)", R)
if !reflect.DeepEqual(got, []Value{3.0, 1.0, 2.0, 7.0}) {
t.Fatalf("push = %#v", got)
}
}
func TestEvalExprArrayYieldsNaN(t *testing.T) {
if v := EvalExpr("[1,2]", numResolver(nil)); !math.IsNaN(v) {
t.Fatalf("array via EvalExpr should be NaN, got %v", v)
}
}
func TestCollectRefsArray(t *testing.T) {
refs := CollectRefs("[{ds:a}, b[0]] ")
keys := map[string]bool{}
for _, r := range refs {
keys[r.DS+":"+r.Name] = true
}
if !keys["ds:a"] || !keys["local:b"] {
t.Fatalf("refs = %#v", refs)
}
}
+5 -2
View File
@@ -1,6 +1,7 @@
package controllogic
import (
"math"
"sync"
lua "github.com/yuin/gopher-lua"
@@ -60,9 +61,11 @@ func (lr *luaRuntime) ensure() error {
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
target := s.CheckString(1)
ds, name, ok := parseRef(target)
var v float64
v := math.NaN()
if ok && lr.curResolve != nil {
v = lr.curResolve(ds, name)
if f, isNum := lr.curResolve(ds, name).(float64); isNum {
v = f
}
}
s.Push(lua.LNumber(v))
return 1
+17
View File
@@ -50,6 +50,15 @@ type Wire struct {
To string `json:"to"`
}
// NodeGroup is a cosmetic, editor-side grouping of nodes. The engine ignores it;
// it is stored and round-tripped so the editor keeps its visual organisation.
type NodeGroup struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Members []string `json:"members"`
Collapsed bool `json:"collapsed,omitempty"`
}
// Graph is a named, independently-enableable control-logic flow.
type Graph struct {
ID string `json:"id"`
@@ -57,8 +66,16 @@ type Graph struct {
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")
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility
Nodes []Node `json:"nodes"`
Wires []Wire `json:"wires"`
Groups []NodeGroup `json:"groups,omitempty"`
// StateVars declares graph-local variables (scalar or array). Live values are
// instantiated in memory per generation from these declarations; only the
// declarations persist. Mirrors the panel-logic statevars feature.
StateVars []StateVar `json:"statevars,omitempty"`
}
func (n Node) param(key string) string {
+77
View File
@@ -0,0 +1,77 @@
package controllogic
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// TestStoreDeleteAndReload covers Delete (with trash backup), the ErrNotFound
// branches, List, and load() re-reading a persisted store from disk.
func TestStoreDeleteAndReload(t *testing.T) {
dir := t.TempDir()
s, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
g := Graph{ID: "g1", Name: "one", 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: %v", err)
}
if err := s.Save(Graph{ID: "g2", Name: "two"}); err != nil {
t.Fatalf("Save g2: %v", err)
}
if got := s.List(); len(got) != 2 {
t.Fatalf("List: want 2, got %d", len(got))
}
// Reload from disk: a fresh Store over the same dir must see both graphs.
s2, err := NewStore(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if got, err := s2.Get("g1"); err != nil || got.Name != "one" {
t.Errorf("reloaded g1 = %+v, %v", got, err)
}
// Delete writes a trash backup then removes the item.
if err := s.Delete("g1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get("g1"); err != ErrNotFound {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
if err := s.Delete("g1"); err != ErrNotFound {
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
}
// A trash file should now exist for g1.
trashDir := filepath.Join(dir, "trash", "controllogic")
entries, err := os.ReadDir(trashDir)
if err != nil || len(entries) == 0 {
t.Errorf("trash dir = %v entries, err %v; want >=1", len(entries), err)
}
}
// TestSplitCSV covers the comma-filter parser, including trimming and the
// empty-input (nil) case.
func TestSplitCSV(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{" ", nil},
{"a", []string{"a"}},
{" a , b ,, c ", []string{"a", "b", "c"}},
}
for _, tc := range cases {
if got := splitCSV(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
+33
View File
@@ -0,0 +1,33 @@
package controllogic
import "testing"
func TestStoreRoundTripStateVars(t *testing.T) {
dir := t.TempDir()
st, err := NewStore(dir) // NewStore takes the storage DIRECTORY
if err != nil {
t.Fatal(err)
}
g := Graph{
ID: "g1",
Name: "with-vars",
StateVars: []StateVar{
{Name: "count", Type: "number", Initial: "0"},
{Name: "buf", Type: "array", Initial: "[1,2]", Elem: "number", Sizing: "capped", Capacity: 5},
},
}
if err := st.Save(g); err != nil { // Save returns only error
t.Fatal(err)
}
st2, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
got, err := st2.Get("g1") // Get returns (Graph, error); ErrNotFound if absent
if err != nil {
t.Fatal(err)
}
if len(got.StateVars) != 2 || got.StateVars[1].Name != "buf" || got.StateVars[1].Capacity != 5 {
t.Fatalf("statevars not round-tripped: %#v", got.StateVars)
}
}
+156
View File
@@ -0,0 +1,156 @@
// Value model for control-logic locals/expressions. A Value is either a scalar
// (float64; booleans are 1/0) or an array ([]Value). This is the Go port of
// web/src/lib/arraypolicy.ts (sizing) plus the asNum/asArr/idx narrowing from
// web/src/lib/expr.ts. Pure, dependency-free.
package controllogic
import (
"encoding/json"
"fmt"
"strings"
)
// Value is a scalar (float64) or an array ([]Value).
type Value = any
// ARRAY_MAX is the global hard cap on dynamic array length (drops oldest).
const ARRAY_MAX = 1_000_000
// StateVar declares a graph-local variable. Mirrors web/src/lib/types.ts StateVar.
type StateVar struct {
Name string `json:"name"`
Type string `json:"type,omitempty"` // number|bool|string|array (default number)
Initial string `json:"initial"` // initial value, stored as a string
Unit string `json:"unit,omitempty"`
Low float64 `json:"low,omitempty"`
High float64 `json:"high,omitempty"`
Elem string `json:"elem,omitempty"` // array-only: number|bool|array
Sizing string `json:"sizing,omitempty"` // array-only: dynamic|capped|fixed
Capacity int `json:"capacity,omitempty"` // array-only
}
func asNum(v Value) (float64, error) {
f, ok := v.(float64)
if !ok {
return 0, fmt.Errorf("expected a number, got an array")
}
return f, nil
}
func asArr(v Value) ([]Value, error) {
a, ok := v.([]Value)
if !ok {
return nil, fmt.Errorf("expected an array, got a number")
}
return a, nil
}
// idxResolve resolves a possibly-negative index against length; range-checked.
func idxResolve(i float64, length int) (int, error) {
k := int(i) // truncates toward zero, matching Math.trunc
if k < 0 {
k = length + k
}
if k < 0 || k >= length {
return 0, fmt.Errorf("index %v out of range (len %d)", i, length)
}
return k, nil
}
// normalizeValue coerces an arbitrary decoded value (e.g. from JSON: float64,
// bool, []interface{}) into a canonical Value (float64 leaves, []Value arrays).
func normalizeValue(v any) Value {
switch t := v.(type) {
case float64:
return t
case float32:
return float64(t)
case int:
return float64(t)
case int64:
return float64(t)
case bool:
if t {
return 1.0
}
return 0.0
case []interface{}:
out := make([]Value, len(t))
for i, e := range t {
out[i] = normalizeValue(e)
}
return out
default:
return 0.0
}
}
func zeroFill(n int) []Value {
if n < 0 {
n = 0
}
out := make([]Value, n)
for i := range out {
out[i] = 0.0
}
return out
}
// parseInitialArray returns the starting contents of an array local. Mirrors
// arraypolicy.ts parseInitialArray.
func parseInitialArray(sv StateVar) []Value {
cap := sv.Capacity
raw := strings.TrimSpace(sv.Initial)
var parsed []Value
if raw != "" {
var j interface{}
if err := json.Unmarshal([]byte(raw), &j); err == nil {
if arr, ok := j.([]interface{}); ok {
parsed = normalizeValue(arr).([]Value)
}
}
}
if sv.Sizing == "fixed" {
if parsed == nil {
return zeroFill(cap)
}
out := make([]Value, 0, cap)
for i := 0; i < len(parsed) && i < cap; i++ {
out = append(out, parsed[i])
}
for len(out) < cap {
out = append(out, 0.0)
}
return out
}
if parsed == nil {
return []Value{}
}
return parsed
}
// applySizing clamps arr to the declared sizing policy. Mirrors arraypolicy.ts.
func applySizing(arr []Value, sv StateVar) []Value {
cap := sv.Capacity
switch sv.Sizing {
case "fixed":
out := make([]Value, 0, cap)
for i := 0; i < len(arr) && i < cap; i++ {
out = append(out, arr[i])
}
for len(out) < cap {
out = append(out, 0.0)
}
return out
case "capped":
if len(arr) > cap {
return arr[len(arr)-cap:]
}
return arr
default:
if len(arr) > ARRAY_MAX {
return arr[len(arr)-ARRAY_MAX:]
}
return arr
}
}
+67
View File
@@ -0,0 +1,67 @@
package controllogic
import (
"reflect"
"testing"
)
func TestAsNumAsArr(t *testing.T) {
if n, err := asNum(3.0); err != nil || n != 3 {
t.Fatalf("asNum(3)=%v,%v", n, err)
}
if _, err := asNum([]Value{1.0}); err == nil {
t.Fatal("asNum(array) should error")
}
if a, err := asArr([]Value{1.0, 2.0}); err != nil || len(a) != 2 {
t.Fatalf("asArr=%v,%v", a, err)
}
if _, err := asArr(3.0); err == nil {
t.Fatal("asArr(number) should error")
}
}
func TestIdxResolve(t *testing.T) {
if k, err := idxResolve(-1, 3); err != nil || k != 2 {
t.Fatalf("idx(-1,3)=%v,%v", k, err)
}
if _, err := idxResolve(3, 3); err == nil {
t.Fatal("idx(3,3) should be out of range")
}
}
func TestNormalizeValue(t *testing.T) {
got := normalizeValue([]interface{}{1.0, true, []interface{}{2.0}})
want := []Value{1.0, 1.0, []Value{2.0}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("normalize=%#v want %#v", got, want)
}
if normalizeValue(5) != Value(5.0) {
t.Fatalf("normalize(int) = %#v", normalizeValue(5))
}
}
func TestParseInitialArray(t *testing.T) {
fixed := parseInitialArray(StateVar{Type: "array", Sizing: "fixed", Capacity: 3, Initial: "[1,2]"})
if !reflect.DeepEqual(fixed, []Value{1.0, 2.0, 0.0}) {
t.Fatalf("fixed init = %#v", fixed)
}
dyn := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: "[5,6,7]"})
if !reflect.DeepEqual(dyn, []Value{5.0, 6.0, 7.0}) {
t.Fatalf("dynamic init = %#v", dyn)
}
empty := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: ""})
if len(empty) != 0 {
t.Fatalf("empty init = %#v", empty)
}
}
func TestApplySizing(t *testing.T) {
capped := applySizing([]Value{1.0, 2.0, 3.0, 4.0}, StateVar{Sizing: "capped", Capacity: 2})
if !reflect.DeepEqual(capped, []Value{3.0, 4.0}) {
t.Fatalf("capped = %#v", capped)
}
fixed := applySizing([]Value{1.0}, StateVar{Sizing: "fixed", Capacity: 3})
if !reflect.DeepEqual(fixed, []Value{1.0, 0.0, 0.0}) {
t.Fatalf("fixed = %#v", fixed)
}
}
+1 -2
View File
@@ -73,8 +73,7 @@ type EPICS struct {
autoSyncFilter string
autoSyncFromArchiver bool
// caCtx is the CA context created in Connect().
Stored as unsafe.Pointer
// caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
// because the C type (ca_client_context *) is opaque. Every goroutine
// that calls CA functions must call caAttachContext(caCtx) first, because
// Go goroutines can run on any OS thread and CA contexts are thread-local.
+216
View File
@@ -0,0 +1,216 @@
package modbus
import (
"encoding/binary"
"fmt"
"io"
"net"
"sync"
"time"
)
// Modbus function codes.
const (
fcReadCoils = 0x01
fcReadDiscrete = 0x02
fcReadHolding = 0x03
fcReadInput = 0x04
fcWriteSingleCoil = 0x05
fcWriteSingleReg = 0x06
fcWriteMultipleRegs = 0x10
)
// client is a minimal Modbus TCP master for a single device address. Requests
// are serialised by mu (Modbus TCP is request/response and the connection is
// shared by all of a device's polled registers). The connection is dialled
// lazily and dropped on any I/O error so the next request reconnects.
type client struct {
addr string
timeout time.Duration
mu sync.Mutex
conn net.Conn
txID uint16
}
func newClient(addr string, timeout time.Duration) *client {
if timeout <= 0 {
timeout = 3 * time.Second
}
return &client{addr: addr, timeout: timeout}
}
func (c *client) close() {
c.mu.Lock()
c.closeLocked()
c.mu.Unlock()
}
func (c *client) closeLocked() {
if c.conn != nil {
_ = c.conn.Close()
c.conn = nil
}
}
// request sends a PDU to unitID and returns the response PDU (function code +
// data). It dials on demand and tears the connection down on error.
func (c *client) request(unitID byte, pdu []byte) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
conn, err := net.DialTimeout("tcp", c.addr, c.timeout)
if err != nil {
return nil, fmt.Errorf("modbus: dial %s: %w", c.addr, err)
}
c.conn = conn
}
resp, err := c.transact(unitID, pdu)
if err != nil {
c.closeLocked()
return nil, err
}
return resp, nil
}
// transact performs one MBAP-framed exchange. Caller holds mu.
func (c *client) transact(unitID byte, pdu []byte) ([]byte, error) {
c.txID++
tx := c.txID
frame := make([]byte, 7+len(pdu))
binary.BigEndian.PutUint16(frame[0:], tx) // transaction id
binary.BigEndian.PutUint16(frame[2:], 0) // protocol id (0 = Modbus)
binary.BigEndian.PutUint16(frame[4:], uint16(1+len(pdu))) // length: unit id + PDU
frame[6] = unitID
copy(frame[7:], pdu)
_ = c.conn.SetDeadline(time.Now().Add(c.timeout))
if _, err := c.conn.Write(frame); err != nil {
return nil, fmt.Errorf("modbus: write: %w", err)
}
head := make([]byte, 7)
if _, err := io.ReadFull(c.conn, head); err != nil {
return nil, fmt.Errorf("modbus: read header: %w", err)
}
if binary.BigEndian.Uint16(head[0:]) != tx {
return nil, fmt.Errorf("modbus: transaction id mismatch")
}
length := binary.BigEndian.Uint16(head[4:])
if length < 2 { // unit id + at least a function code
return nil, fmt.Errorf("modbus: short frame length %d", length)
}
body := make([]byte, length-1) // header already consumed the unit id
if _, err := io.ReadFull(c.conn, body); err != nil {
return nil, fmt.Errorf("modbus: read body: %w", err)
}
fc := body[0]
if fc&0x80 != 0 { // exception response
var ex byte
if len(body) >= 2 {
ex = body[1]
}
return nil, fmt.Errorf("modbus: exception 0x%02x (%s)", ex, exceptionText(ex))
}
return body, nil
}
// readRegisters reads `quantity` 16-bit registers via fc (holding or input).
func (c *client) readRegisters(unitID, fc byte, addr, quantity uint16) ([]uint16, error) {
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
resp, err := c.request(unitID, pdu)
if err != nil {
return nil, err
}
if len(resp) < 2 {
return nil, fmt.Errorf("modbus: short register response")
}
byteCount := int(resp[1])
if byteCount != int(quantity)*2 || len(resp) < 2+byteCount {
return nil, fmt.Errorf("modbus: register byte count %d (want %d)", byteCount, quantity*2)
}
regs := make([]uint16, quantity)
for i := range regs {
regs[i] = binary.BigEndian.Uint16(resp[2+i*2:])
}
return regs, nil
}
// readBits reads `quantity` bits via fc (coils or discrete inputs).
func (c *client) readBits(unitID, fc byte, addr, quantity uint16) ([]bool, error) {
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
resp, err := c.request(unitID, pdu)
if err != nil {
return nil, err
}
if len(resp) < 2 {
return nil, fmt.Errorf("modbus: short bit response")
}
byteCount := int(resp[1])
if len(resp) < 2+byteCount {
return nil, fmt.Errorf("modbus: bit byte count %d exceeds frame", byteCount)
}
bits := make([]bool, quantity)
for i := range bits {
idx := 2 + i/8
if idx >= len(resp) {
break
}
bits[i] = resp[idx]&(1<<(uint(i)%8)) != 0
}
return bits, nil
}
func (c *client) writeSingleRegister(unitID byte, addr, value uint16) error {
pdu := []byte{fcWriteSingleReg, byte(addr >> 8), byte(addr), byte(value >> 8), byte(value)}
_, err := c.request(unitID, pdu)
return err
}
func (c *client) writeMultipleRegisters(unitID byte, addr uint16, values []uint16) error {
pdu := make([]byte, 6+len(values)*2)
pdu[0] = fcWriteMultipleRegs
binary.BigEndian.PutUint16(pdu[1:], addr)
binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
pdu[5] = byte(len(values) * 2)
for i, v := range values {
binary.BigEndian.PutUint16(pdu[6+i*2:], v)
}
_, err := c.request(unitID, pdu)
return err
}
func (c *client) writeSingleCoil(unitID byte, addr uint16, on bool) error {
var v uint16
if on {
v = 0xFF00
}
pdu := []byte{fcWriteSingleCoil, byte(addr >> 8), byte(addr), byte(v >> 8), byte(v)}
_, err := c.request(unitID, pdu)
return err
}
func exceptionText(code byte) string {
switch code {
case 0x01:
return "illegal function"
case 0x02:
return "illegal data address"
case 0x03:
return "illegal data value"
case 0x04:
return "server device failure"
case 0x05:
return "acknowledge"
case 0x06:
return "server device busy"
case 0x0B:
return "gateway target failed to respond"
default:
return "unknown"
}
}
+152
View File
@@ -0,0 +1,152 @@
package modbus
import (
"fmt"
"strings"
"github.com/uopi/uopi/internal/datasource"
)
// Config is the [datasource.modbus] section. Devices share no connection state;
// each is polled independently over its own TCP socket.
type Config struct {
Enabled bool `toml:"enabled"`
// PollIntervalMs is the default polling period for every register that does
// not override it. Zero → 1000 ms.
PollIntervalMs int `toml:"poll_interval_ms"`
Devices []Device `toml:"devices"`
}
// Device is one Modbus TCP slave. Address is "host:port" (default port 502 is
// appended if absent). UnitID is the Modbus unit/slave identifier (0255).
type Device struct {
Name string `toml:"name"`
Address string `toml:"address"`
UnitID uint8 `toml:"unit_id"`
TimeoutMs int `toml:"timeout_ms"`
Registers []Register `toml:"registers"`
}
// Register describes one logical signal mapped onto a Modbus address.
//
// - Kind selects the address space / function code:
// "holding" (FC03/06/10), "input" (FC04, read-only),
// "coil" (FC01/05, bool), "discrete" (FC02, read-only bool).
// - Encoding selects how holding/input words are decoded:
// "uint16", "int16", "uint32", "int32", "float32", "float64".
// Ignored for coil/discrete (always bool).
// - WordOrder is "big" (default, high word first) or "little" for the
// multi-word encodings.
// - Scale/Offset transform the raw numeric value: value*Scale + Offset.
// Scale 0 is treated as 1.
type Register struct {
Name string `toml:"name"`
Kind string `toml:"kind"`
Address uint16 `toml:"address"`
Encoding string `toml:"encoding"`
WordOrder string `toml:"word_order"`
Unit string `toml:"unit"`
Scale float64 `toml:"scale"`
Offset float64 `toml:"offset"`
Min float64 `toml:"min"`
Max float64 `toml:"max"`
Writable bool `toml:"writable"`
Description string `toml:"description"`
}
// register kinds.
const (
kindHolding = "holding"
kindInput = "input"
kindCoil = "coil"
kindDiscrete = "discrete"
)
// isBool reports whether the register addresses a single-bit space.
func (r Register) isBool() bool {
return r.kind() == kindCoil || r.kind() == kindDiscrete
}
func (r Register) kind() string {
if r.Kind == "" {
return kindHolding
}
return strings.ToLower(r.Kind)
}
func (r Register) encoding() string {
if r.Encoding == "" {
return "uint16"
}
return strings.ToLower(r.Encoding)
}
func (r Register) littleWordOrder() bool {
return strings.ToLower(r.WordOrder) == "little"
}
func (r Register) scale() float64 {
if r.Scale == 0 {
return 1
}
return r.Scale
}
// wordCount returns the number of 16-bit registers the encoding occupies.
func (r Register) wordCount() (int, error) {
switch r.encoding() {
case "uint16", "int16":
return 1, nil
case "uint32", "int32", "float32":
return 2, nil
case "float64":
return 4, nil
default:
return 0, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
}
// dataType maps the register to a datasource value type.
func (r Register) dataType() datasource.DataType {
if r.isBool() {
return datasource.TypeBool
}
// Integer encodings with no fractional scaling stay integers; anything
// scaled, offset, or float-encoded becomes a float64.
switch r.encoding() {
case "float32", "float64":
return datasource.TypeFloat64
default:
if r.scale() == 1 && r.Offset == 0 {
return datasource.TypeInt64
}
return datasource.TypeFloat64
}
}
// writable reports whether writes are permitted. Input registers and discrete
// inputs are read-only regardless of the Writable flag.
func (r Register) writable() bool {
switch r.kind() {
case kindInput, kindDiscrete:
return false
default:
return r.Writable
}
}
// metadata builds the datasource.Metadata for this register under signal name
// "device:register".
func (r Register) metadata(device string) datasource.Metadata {
return datasource.Metadata{
Name: device + ":" + r.Name,
Type: r.dataType(),
Unit: r.Unit,
Description: r.Description,
DisplayLow: r.Min,
DisplayHigh: r.Max,
DriveLow: r.Min,
DriveHigh: r.Max,
Writable: r.writable(),
}
}
+89
View File
@@ -0,0 +1,89 @@
package modbus
import (
"fmt"
"math"
)
// orderWords returns the registers in big-word-first order, reversing them when
// the register declares little word order. The slice is copied so the caller's
// data is left untouched.
func (r Register) orderWords(words []uint16) []uint16 {
if !r.littleWordOrder() {
return words
}
out := make([]uint16, len(words))
for i, w := range words {
out[len(words)-1-i] = w
}
return out
}
// decode converts the raw registers into the register's numeric value and
// applies scale/offset. The result type is int64 or float64 per dataType.
func (r Register) decode(words []uint16) (any, error) {
w := r.orderWords(words)
var raw float64
var rawInt int64
switch r.encoding() {
case "uint16":
rawInt = int64(w[0])
raw = float64(w[0])
case "int16":
rawInt = int64(int16(w[0]))
raw = float64(int16(w[0]))
case "uint32":
u := uint32(w[0])<<16 | uint32(w[1])
rawInt = int64(u)
raw = float64(u)
case "int32":
u := uint32(w[0])<<16 | uint32(w[1])
rawInt = int64(int32(u))
raw = float64(int32(u))
case "float32":
u := uint32(w[0])<<16 | uint32(w[1])
raw = float64(math.Float32frombits(u))
case "float64":
u := uint64(w[0])<<48 | uint64(w[1])<<32 | uint64(w[2])<<16 | uint64(w[3])
raw = math.Float64frombits(u)
default:
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
// Integer fast-path: no scaling/offset, integer encoding.
if r.scale() == 1 && r.Offset == 0 {
switch r.encoding() {
case "uint16", "int16", "uint32", "int32":
return rawInt, nil
}
}
return raw*r.scale() + r.Offset, nil
}
// encode converts a value destined for a Write back into raw registers,
// inverting scale/offset. Only the holding-register encodings are writable.
func (r Register) encode(value float64) ([]uint16, error) {
v := (value - r.Offset) / r.scale()
var words []uint16
switch r.encoding() {
case "uint16":
words = []uint16{uint16(int64(math.Round(v)))}
case "int16":
words = []uint16{uint16(int16(int64(math.Round(v))))}
case "uint32":
u := uint32(int64(math.Round(v)))
words = []uint16{uint16(u >> 16), uint16(u)}
case "int32":
u := uint32(int32(int64(math.Round(v))))
words = []uint16{uint16(u >> 16), uint16(u)}
case "float32":
u := math.Float32bits(float32(v))
words = []uint16{uint16(u >> 16), uint16(u)}
case "float64":
u := math.Float64bits(v)
words = []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
default:
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
}
return r.orderWords(words), nil
}
+115
View File
@@ -0,0 +1,115 @@
package modbus
import (
"math"
"testing"
"github.com/uopi/uopi/internal/datasource"
)
func TestDecodeEncodings(t *testing.T) {
cases := []struct {
name string
reg Register
words []uint16
want any
}{
{"uint16", Register{Encoding: "uint16"}, []uint16{42}, int64(42)},
{"int16 neg", Register{Encoding: "int16"}, []uint16{0xFFFF}, int64(-1)},
{"uint32", Register{Encoding: "uint32"}, []uint16{0x0001, 0x0000}, int64(65536)},
{"int32 neg", Register{Encoding: "int32"}, []uint16{0xFFFF, 0xFFFF}, int64(-1)},
{"scaled", Register{Encoding: "int16", Scale: 0.1}, []uint16{235}, 23.5},
{"float32", Register{Encoding: "float32"}, f32Words(3.5), 3.5},
{"float64", Register{Encoding: "float64"}, f64Words(2.25), 2.25},
}
for _, tc := range cases {
got, err := tc.reg.decode(tc.words)
if err != nil {
t.Errorf("%s: decode error %v", tc.name, err)
continue
}
switch w := tc.want.(type) {
case int64:
if got != w {
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
}
case float64:
gf, ok := got.(float64)
if !ok || math.Abs(gf-w) > 1e-6 {
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
}
}
}
}
func TestWordOrder(t *testing.T) {
big := Register{Encoding: "uint32", WordOrder: "big"}
little := Register{Encoding: "uint32", WordOrder: "little"}
words := []uint16{0x0001, 0x0002} // big: 0x00010002, little reverses to 0x00020001
gb, _ := big.decode(words)
gl, _ := little.decode(words)
if gb != int64(0x00010002) {
t.Errorf("big = %v, want %d", gb, 0x00010002)
}
if gl != int64(0x00020001) {
t.Errorf("little = %v, want %d", gl, 0x00020001)
}
}
func TestEncodeRoundTrip(t *testing.T) {
for _, enc := range []string{"uint16", "int16", "uint32", "int32", "float32", "float64"} {
reg := Register{Encoding: enc}
words, err := reg.encode(123)
if err != nil {
t.Fatalf("%s encode: %v", enc, err)
}
got, err := reg.decode(words)
if err != nil {
t.Fatalf("%s decode: %v", enc, err)
}
var f float64
switch v := got.(type) {
case int64:
f = float64(v)
case float64:
f = v
}
if math.Abs(f-123) > 1e-3 {
t.Errorf("%s round-trip = %v, want 123", enc, f)
}
}
}
func TestDataTypeAndWritable(t *testing.T) {
if got := (Register{Kind: "coil"}).dataType(); got != datasource.TypeBool {
t.Errorf("coil type = %v, want bool", got)
}
if got := (Register{Encoding: "float32"}).dataType(); got != datasource.TypeFloat64 {
t.Errorf("float type = %v, want float64", got)
}
if got := (Register{Encoding: "uint16", Scale: 0.5}).dataType(); got != datasource.TypeFloat64 {
t.Errorf("scaled int type = %v, want float64", got)
}
if got := (Register{Encoding: "uint16"}).dataType(); got != datasource.TypeInt64 {
t.Errorf("plain int type = %v, want int64", got)
}
if (Register{Kind: "input", Writable: true}).writable() {
t.Error("input register must be read-only")
}
if (Register{Kind: "discrete", Writable: true}).writable() {
t.Error("discrete input must be read-only")
}
if !(Register{Kind: "holding", Writable: true}).writable() {
t.Error("writable holding should be writable")
}
}
func f32Words(v float32) []uint16 {
u := math.Float32bits(v)
return []uint16{uint16(u >> 16), uint16(u)}
}
func f64Words(v float64) []uint16 {
u := math.Float64bits(v)
return []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
}
+258
View File
@@ -0,0 +1,258 @@
// Package modbus implements a Modbus TCP data source. Each configured device is
// polled over its own TCP connection; registers are exposed as signals named
// "device:register". Reads use the holding/input/coil/discrete function codes;
// writable holding registers and coils accept Write.
package modbus
import (
"context"
"fmt"
"strings"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const defaultPollInterval = time.Second
// deviceClient bundles a parsed device with its wire client and register lookup.
type deviceClient struct {
dev Device
cli *client
byName map[string]Register
}
// Modbus is a datasource.DataSource backed by one or more Modbus TCP devices.
type Modbus struct {
pollInterval time.Duration
devices map[string]*deviceClient // device name → client
signals map[string]signalRef // "device:register" → ref
}
type signalRef struct {
device string
reg Register
}
// New builds a Modbus source from config. It does not dial; connections are
// established lazily on first poll/write.
func New(cfg Config) (*Modbus, error) {
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
if poll <= 0 {
poll = defaultPollInterval
}
m := &Modbus{
pollInterval: poll,
devices: make(map[string]*deviceClient),
signals: make(map[string]signalRef),
}
for _, dev := range cfg.Devices {
if dev.Name == "" || dev.Address == "" {
return nil, fmt.Errorf("modbus: device needs name and address")
}
if _, dup := m.devices[dev.Name]; dup {
return nil, fmt.Errorf("modbus: duplicate device %q", dev.Name)
}
addr := dev.Address
if !strings.Contains(addr, ":") {
addr += ":502"
}
dc := &deviceClient{
dev: dev,
cli: newClient(addr, time.Duration(dev.TimeoutMs)*time.Millisecond),
byName: make(map[string]Register),
}
for _, reg := range dev.Registers {
if reg.Name == "" {
return nil, fmt.Errorf("modbus: device %q has a register with no name", dev.Name)
}
if _, err := reg.wordCount(); !reg.isBool() && err != nil {
return nil, err
}
if _, dup := dc.byName[reg.Name]; dup {
return nil, fmt.Errorf("modbus: device %q duplicate register %q", dev.Name, reg.Name)
}
dc.byName[reg.Name] = reg
m.signals[dev.Name+":"+reg.Name] = signalRef{device: dev.Name, reg: reg}
}
m.devices[dev.Name] = dc
}
return m, nil
}
// Name implements datasource.DataSource.
func (m *Modbus) Name() string { return "modbus" }
// Connect is a no-op; TCP connections are dialled lazily per device.
func (m *Modbus) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every configured register.
func (m *Modbus) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
out := make([]datasource.Metadata, 0, len(m.signals))
for _, ref := range m.signals {
out = append(out, ref.reg.metadata(ref.device))
}
return out, nil
}
// GetMetadata returns metadata for one signal.
func (m *Modbus) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
ref, ok := m.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return ref.reg.metadata(ref.device), nil
}
// readSignal performs one synchronous read of a register's current value.
func (m *Modbus) readSignal(ref signalRef) (datasource.Value, error) {
dc := m.devices[ref.device]
reg := ref.reg
now := time.Now()
if reg.isBool() {
fc := byte(fcReadCoils)
if reg.kind() == kindDiscrete {
fc = fcReadDiscrete
}
bits, err := dc.cli.readBits(dc.dev.UnitID, fc, reg.Address, 1)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: now, Data: bits[0], Quality: datasource.QualityGood}, nil
}
count, err := reg.wordCount()
if err != nil {
return datasource.Value{}, err
}
fc := byte(fcReadHolding)
if reg.kind() == kindInput {
fc = fcReadInput
}
words, err := dc.cli.readRegisters(dc.dev.UnitID, fc, reg.Address, uint16(count))
if err != nil {
return datasource.Value{}, err
}
data, err := reg.decode(words)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: now, Data: data, Quality: datasource.QualityGood}, nil
}
// Subscribe polls the register at the configured interval and pushes values
// into ch. On a read error a QualityBad value is emitted and polling continues.
func (m *Modbus) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
ref, ok := m.signals[signal]
if !ok {
return nil, datasource.ErrNotFound
}
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(m.pollInterval)
defer ticker.Stop()
emit := func() {
v, err := m.readSignal(ref)
if err != nil {
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
}
select {
case ch <- v:
case <-ctx.Done():
}
}
emit() // first reading without waiting a full interval
for {
select {
case <-ticker.C:
emit()
case <-ctx.Done():
return
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write sets a writable holding register or coil.
func (m *Modbus) Write(_ context.Context, signal string, value any) error {
ref, ok := m.signals[signal]
if !ok {
return datasource.ErrNotFound
}
reg := ref.reg
if !reg.writable() {
return datasource.ErrNotWritable
}
dc := m.devices[ref.device]
if reg.isBool() {
on, err := toBool(value)
if err != nil {
return err
}
return dc.cli.writeSingleCoil(dc.dev.UnitID, reg.Address, on)
}
f, err := toFloat(value)
if err != nil {
return err
}
words, err := reg.encode(f)
if err != nil {
return err
}
if len(words) == 1 {
return dc.cli.writeSingleRegister(dc.dev.UnitID, reg.Address, words[0])
}
return dc.cli.writeMultipleRegisters(dc.dev.UnitID, reg.Address, words)
}
// History is unavailable for Modbus devices.
func (m *Modbus) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// Close tears down every device connection.
func (m *Modbus) Close() {
for _, dc := range m.devices {
dc.cli.close()
}
}
func toFloat(v any) (float64, error) {
switch x := v.(type) {
case float64:
return x, nil
case float32:
return float64(x), nil
case int:
return float64(x), nil
case int64:
return float64(x), nil
case bool:
if x {
return 1, nil
}
return 0, nil
default:
return 0, fmt.Errorf("modbus: cannot write value of type %T", v)
}
}
func toBool(v any) (bool, error) {
switch x := v.(type) {
case bool:
return x, nil
case float64:
return x != 0, nil
case int:
return x != 0, nil
case int64:
return x != 0, nil
default:
return false, fmt.Errorf("modbus: cannot write bool value of type %T", v)
}
}
+364
View File
@@ -0,0 +1,364 @@
package modbus
import (
"context"
"encoding/binary"
"io"
"net"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
// mockServer is an in-process Modbus TCP slave for tests. It serves a small
// register/coil store and records writes. Only the function codes exercised by
// the data source are implemented.
type mockServer struct {
ln net.Listener
mu sync.Mutex
holding map[uint16]uint16
input map[uint16]uint16
coils map[uint16]bool
discrete map[uint16]bool
lastWrite []uint16 // registers from the most recent write
}
func newMockServer(t *testing.T) *mockServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
s := &mockServer{
ln: ln,
holding: map[uint16]uint16{},
input: map[uint16]uint16{},
coils: map[uint16]bool{},
discrete: map[uint16]bool{},
}
go s.serve()
t.Cleanup(func() { ln.Close() })
return s
}
func (s *mockServer) addr() string { return s.ln.Addr().String() }
func (s *mockServer) serve() {
for {
conn, err := s.ln.Accept()
if err != nil {
return
}
go s.handle(conn)
}
}
func (s *mockServer) handle(conn net.Conn) {
defer conn.Close()
for {
head := make([]byte, 7)
if _, err := io.ReadFull(conn, head); err != nil {
return
}
tx := binary.BigEndian.Uint16(head[0:])
length := binary.BigEndian.Uint16(head[4:])
body := make([]byte, length-1)
if _, err := io.ReadFull(conn, body); err != nil {
return
}
resp := s.respond(body)
out := make([]byte, 7+len(resp))
binary.BigEndian.PutUint16(out[0:], tx)
binary.BigEndian.PutUint16(out[2:], 0)
binary.BigEndian.PutUint16(out[4:], uint16(1+len(resp)))
out[6] = head[6]
copy(out[7:], resp)
if _, err := conn.Write(out); err != nil {
return
}
}
}
func (s *mockServer) respond(pdu []byte) []byte {
s.mu.Lock()
defer s.mu.Unlock()
fc := pdu[0]
switch fc {
case fcReadHolding, fcReadInput:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
out := []byte{fc, byte(qty * 2)}
src := s.holding
if fc == fcReadInput {
src = s.input
}
for i := uint16(0); i < qty; i++ {
out = binary.BigEndian.AppendUint16(out, src[addr+i])
}
return out
case fcReadCoils, fcReadDiscrete:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
nbytes := (int(qty) + 7) / 8
out := []byte{fc, byte(nbytes)}
bits := make([]byte, nbytes)
src := s.coils
if fc == fcReadDiscrete {
src = s.discrete
}
for i := uint16(0); i < qty; i++ {
if src[addr+i] {
bits[i/8] |= 1 << (i % 8)
}
}
return append(out, bits...)
case fcWriteSingleReg:
addr := binary.BigEndian.Uint16(pdu[1:])
val := binary.BigEndian.Uint16(pdu[3:])
s.holding[addr] = val
s.lastWrite = []uint16{val}
return pdu // echo
case fcWriteSingleCoil:
addr := binary.BigEndian.Uint16(pdu[1:])
s.coils[addr] = binary.BigEndian.Uint16(pdu[3:]) == 0xFF00
return pdu
case fcWriteMultipleRegs:
addr := binary.BigEndian.Uint16(pdu[1:])
qty := binary.BigEndian.Uint16(pdu[3:])
s.lastWrite = nil
for i := uint16(0); i < qty; i++ {
v := binary.BigEndian.Uint16(pdu[6+i*2:])
s.holding[addr+i] = v
s.lastWrite = append(s.lastWrite, v)
}
return append([]byte{fc}, pdu[1:5]...)
default:
return []byte{fc | 0x80, 0x01}
}
}
func (s *mockServer) setHolding(addr, val uint16) {
s.mu.Lock()
s.holding[addr] = val
s.mu.Unlock()
}
func (s *mockServer) setInput(addr, val uint16) {
s.mu.Lock()
s.input[addr] = val
s.mu.Unlock()
}
func (s *mockServer) setDiscrete(addr uint16, on bool) {
s.mu.Lock()
s.discrete[addr] = on
s.mu.Unlock()
}
func testConfig(addr string) Config {
return Config{
Enabled: true,
PollIntervalMs: 20,
Devices: []Device{{
Name: "dev",
Address: addr,
UnitID: 1,
Registers: []Register{
{Name: "temp", Kind: "holding", Address: 10, Encoding: "int16", Scale: 0.1, Unit: "C", Writable: true},
{Name: "count", Kind: "holding", Address: 20, Encoding: "uint16"},
{Name: "big", Kind: "input", Address: 30, Encoding: "uint32"},
{Name: "flag", Kind: "discrete", Address: 5},
{Name: "relay", Kind: "coil", Address: 6, Writable: true},
{Name: "sp", Kind: "holding", Address: 40, Encoding: "float32", Writable: true},
},
}},
}
}
func TestReadRegisters(t *testing.T) {
srv := newMockServer(t)
srv.setHolding(10, 235) // int16, scale 0.1 → 23.5
srv.setHolding(20, 7)
srv.setInput(30, 0)
srv.setInput(31, 1000) // uint32 big-word-first: low word at 31
srv.setDiscrete(5, true)
m, err := New(testConfig(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer m.Close()
temp, err := m.readSignal(m.signals["dev:temp"])
if err != nil {
t.Fatalf("read temp: %v", err)
}
if f, ok := temp.Data.(float64); !ok || f < 23.49 || f > 23.51 {
t.Errorf("temp = %v (%T), want 23.5", temp.Data, temp.Data)
}
count, err := m.readSignal(m.signals["dev:count"])
if err != nil {
t.Fatalf("read count: %v", err)
}
if v, ok := count.Data.(int64); !ok || v != 7 {
t.Errorf("count = %v (%T), want int64 7", count.Data, count.Data)
}
big, err := m.readSignal(m.signals["dev:big"])
if err != nil {
t.Fatalf("read big: %v", err)
}
if v, ok := big.Data.(int64); !ok || v != 1000 {
t.Errorf("big = %v (%T), want int64 1000", big.Data, big.Data)
}
flag, err := m.readSignal(m.signals["dev:flag"])
if err != nil {
t.Fatalf("read flag: %v", err)
}
if b, ok := flag.Data.(bool); !ok || !b {
t.Errorf("flag = %v, want true", flag.Data)
}
}
func TestWriteRoundTrip(t *testing.T) {
srv := newMockServer(t)
m, err := New(testConfig(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer m.Close()
ctx := context.Background()
// Scaled int16: writing 23.5 with scale 0.1 should store raw 235.
if err := m.Write(ctx, "dev:temp", 23.5); err != nil {
t.Fatalf("write temp: %v", err)
}
srv.mu.Lock()
raw := srv.holding[10]
srv.mu.Unlock()
if raw != 235 {
t.Errorf("holding[10] = %d, want 235", raw)
}
// Coil write.
if err := m.Write(ctx, "dev:relay", true); err != nil {
t.Fatalf("write relay: %v", err)
}
srv.mu.Lock()
on := srv.coils[6]
srv.mu.Unlock()
if !on {
t.Error("coil 6 not set")
}
// float32 multi-register write.
if err := m.Write(ctx, "dev:sp", 12.5); err != nil {
t.Fatalf("write sp: %v", err)
}
got, err := m.readSignal(m.signals["dev:sp"])
if err != nil {
t.Fatalf("read sp: %v", err)
}
if f, ok := got.Data.(float64); !ok || f < 12.49 || f > 12.51 {
t.Errorf("sp = %v, want 12.5", got.Data)
}
}
func TestWriteErrors(t *testing.T) {
srv := newMockServer(t)
m, _ := New(testConfig(srv.addr()))
defer m.Close()
ctx := context.Background()
if err := m.Write(ctx, "dev:missing", 1); err != datasource.ErrNotFound {
t.Errorf("missing write err = %v, want ErrNotFound", err)
}
// Input register is read-only.
if err := m.Write(ctx, "dev:big", 1); err != datasource.ErrNotWritable {
t.Errorf("input write err = %v, want ErrNotWritable", err)
}
}
func TestSubscribe(t *testing.T) {
srv := newMockServer(t)
srv.setHolding(20, 42)
m, _ := New(testConfig(srv.addr()))
defer m.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := m.Subscribe(ctx, "dev:count", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityGood {
t.Errorf("quality = %v, want good", v.Quality)
}
if iv, ok := v.Data.(int64); !ok || iv != 42 {
t.Errorf("first value = %v, want 42", v.Data)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for first value")
}
}
func TestSubscribeBadQualityOnError(t *testing.T) {
// Point at a closed port so reads fail; expect QualityBad, not a hang.
cfg := testConfig("127.0.0.1:1") // port 1: connection refused
m, _ := New(cfg)
defer m.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 1)
stop, err := m.Subscribe(ctx, "dev:count", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityBad {
t.Errorf("quality = %v, want bad", v.Quality)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestNewValidation(t *testing.T) {
if _, err := New(Config{Devices: []Device{{Name: "", Address: "x"}}}); err == nil {
t.Error("expected error for missing device name")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
t.Error("expected error for duplicate device")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r", Encoding: "bogus"}}}}}); err == nil {
t.Error("expected error for bad encoding")
}
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r"}, {Name: "r"}}}}}); err == nil {
t.Error("expected error for duplicate register")
}
}
func TestSubscribeUnknownSignal(t *testing.T) {
m, _ := New(testConfig("127.0.0.1:502"))
defer m.Close()
if _, err := m.Subscribe(context.Background(), "nope", nil); err != datasource.ErrNotFound {
t.Errorf("err = %v, want ErrNotFound", err)
}
if _, err := m.GetMetadata(context.Background(), "nope"); err != datasource.ErrNotFound {
t.Errorf("meta err = %v, want ErrNotFound", err)
}
}
+75
View File
@@ -0,0 +1,75 @@
package scpi
import "github.com/uopi/uopi/internal/datasource"
// Config is the [datasource.scpi] section. Each instrument is polled
// independently over its own TCP socket.
type Config struct {
Enabled bool `toml:"enabled"`
// PollIntervalMs is the default polling period for channels that do not
// override it. Zero → 1000 ms.
PollIntervalMs int `toml:"poll_interval_ms"`
Instruments []Instrument `toml:"instruments"`
}
// Instrument is one SCPI device reachable over a raw TCP socket. Address is
// "host:port"; the conventional SCPI-raw port 5025 is appended if absent.
type Instrument struct {
Name string `toml:"name"`
// Transport selects the link type. "raw" (default) is line-based SCPI over
// TCP. Reserved: "vxi11" (not yet implemented).
Transport string `toml:"transport"`
Address string `toml:"address"`
TimeoutMs int `toml:"timeout_ms"`
// Terminator is appended to every command. Empty → "\n".
Terminator string `toml:"terminator"`
Channels []Channel `toml:"channels"`
}
// Channel maps a SCPI query/command pair onto a signal named
// "instrument:channel".
type Channel struct {
Name string `toml:"name"`
// Query is the SCPI command whose response is the channel value,
// e.g. "MEAS:VOLT?". Required.
Query string `toml:"query"`
// WriteCmd is a printf-style template used by Write; "%v" is replaced with
// the value, e.g. "VOLT %v". Empty → channel is read-only.
WriteCmd string `toml:"write_cmd"`
// Type is the value type: "float" (default), "string", "int", or "bool".
Type string `toml:"type"`
Unit string `toml:"unit"`
Min float64 `toml:"min"`
Max float64 `toml:"max"`
PollIntervalMs int `toml:"poll_interval_ms"`
Description string `toml:"description"`
}
func (c Channel) dataType() datasource.DataType {
switch c.Type {
case "string":
return datasource.TypeString
case "int":
return datasource.TypeInt64
case "bool":
return datasource.TypeBool
default:
return datasource.TypeFloat64
}
}
func (c Channel) writable() bool { return c.WriteCmd != "" }
func (c Channel) metadata(instrument string) datasource.Metadata {
return datasource.Metadata{
Name: instrument + ":" + c.Name,
Type: c.dataType(),
Unit: c.Unit,
Description: c.Description,
DisplayLow: c.Min,
DisplayHigh: c.Max,
DriveLow: c.Min,
DriveHigh: c.Max,
Writable: c.writable(),
}
}
+237
View File
@@ -0,0 +1,237 @@
// Package scpi implements a SCPI instrument data source. Each configured
// instrument is reached over a raw TCP socket (line-based SCPI, the "SCPI raw" /
// port 5025 convention); a VXI-11 transport is reserved for later. Channels are
// exposed as signals named "instrument:channel" and polled at a configurable
// interval. Channels with a write_cmd template accept Write.
package scpi
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/uopi/uopi/internal/datasource"
)
const defaultPollInterval = time.Second
type instrumentConn struct {
inst Instrument
tr transport
}
// Scpi is a datasource.DataSource backed by one or more SCPI instruments.
type Scpi struct {
pollInterval time.Duration
instruments map[string]*instrumentConn
signals map[string]signalRef // "instrument:channel" → ref
}
type signalRef struct {
instrument string
ch Channel
}
// New builds a SCPI source from config. It does not dial; connections are
// established lazily on first poll/write.
func New(cfg Config) (*Scpi, error) {
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
if poll <= 0 {
poll = defaultPollInterval
}
s := &Scpi{
pollInterval: poll,
instruments: make(map[string]*instrumentConn),
signals: make(map[string]signalRef),
}
for _, inst := range cfg.Instruments {
if inst.Name == "" || inst.Address == "" {
return nil, fmt.Errorf("scpi: instrument needs name and address")
}
if _, dup := s.instruments[inst.Name]; dup {
return nil, fmt.Errorf("scpi: duplicate instrument %q", inst.Name)
}
tr, err := newTransport(inst)
if err != nil {
return nil, err
}
ic := &instrumentConn{inst: inst, tr: tr}
for _, ch := range inst.Channels {
if ch.Name == "" || ch.Query == "" {
return nil, fmt.Errorf("scpi: instrument %q channel needs name and query", inst.Name)
}
key := inst.Name + ":" + ch.Name
if _, dup := s.signals[key]; dup {
return nil, fmt.Errorf("scpi: instrument %q duplicate channel %q", inst.Name, ch.Name)
}
s.signals[key] = signalRef{instrument: inst.Name, ch: ch}
}
s.instruments[inst.Name] = ic
}
return s, nil
}
// newTransport selects the transport implementation for an instrument.
func newTransport(inst Instrument) (transport, error) {
addr := inst.Address
switch strings.ToLower(inst.Transport) {
case "", "raw":
if !strings.Contains(addr, ":") {
addr += ":5025"
}
return newRawSocket(addr, time.Duration(inst.TimeoutMs)*time.Millisecond, inst.Terminator), nil
case "vxi11":
return nil, fmt.Errorf("scpi: vxi11 transport not yet implemented")
default:
return nil, fmt.Errorf("scpi: unknown transport %q", inst.Transport)
}
}
// Name implements datasource.DataSource.
func (s *Scpi) Name() string { return "scpi" }
// Connect is a no-op; connections are dialled lazily per instrument.
func (s *Scpi) Connect(_ context.Context) error { return nil }
// ListSignals returns metadata for every configured channel.
func (s *Scpi) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
out := make([]datasource.Metadata, 0, len(s.signals))
for _, ref := range s.signals {
out = append(out, ref.ch.metadata(ref.instrument))
}
return out, nil
}
// GetMetadata returns metadata for one signal.
func (s *Scpi) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
ref, ok := s.signals[signal]
if !ok {
return datasource.Metadata{}, datasource.ErrNotFound
}
return ref.ch.metadata(ref.instrument), nil
}
// readSignal performs one synchronous query of a channel.
func (s *Scpi) readSignal(ref signalRef) (datasource.Value, error) {
ic := s.instruments[ref.instrument]
resp, err := ic.tr.query(ref.ch.Query)
if err != nil {
return datasource.Value{}, err
}
data, err := parseValue(ref.ch.dataType(), resp)
if err != nil {
return datasource.Value{}, err
}
return datasource.Value{Timestamp: time.Now(), Data: data, Quality: datasource.QualityGood}, nil
}
// Subscribe polls the channel at its configured interval (falling back to the
// source default) and pushes values into ch. A query error emits QualityBad and
// polling continues.
func (s *Scpi) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
ref, ok := s.signals[signal]
if !ok {
return nil, datasource.ErrNotFound
}
interval := s.pollInterval
if ref.ch.PollIntervalMs > 0 {
interval = time.Duration(ref.ch.PollIntervalMs) * time.Millisecond
}
ctx, cancel := context.WithCancel(ctx)
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
emit := func() {
v, err := s.readSignal(ref)
if err != nil {
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
}
select {
case ch <- v:
case <-ctx.Done():
}
}
emit()
for {
select {
case <-ticker.C:
emit()
case <-ctx.Done():
return
}
}
}()
return datasource.CancelFunc(cancel), nil
}
// Write sends the channel's write_cmd template with the value substituted.
func (s *Scpi) Write(_ context.Context, signal string, value any) error {
ref, ok := s.signals[signal]
if !ok {
return datasource.ErrNotFound
}
if !ref.ch.writable() {
return datasource.ErrNotWritable
}
ic := s.instruments[ref.instrument]
cmd := formatWrite(ref.ch.WriteCmd, value)
return ic.tr.write(cmd)
}
// History is unavailable for SCPI instruments.
func (s *Scpi) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
return nil, datasource.ErrHistoryUnavailable
}
// Close tears down every instrument connection.
func (s *Scpi) Close() {
for _, ic := range s.instruments {
ic.tr.close()
}
}
// formatWrite substitutes value into the write template. A "%" in the template
// is treated as a printf verb; otherwise the value is appended after a space.
func formatWrite(tmpl string, value any) string {
if strings.Contains(tmpl, "%") {
return fmt.Sprintf(tmpl, value)
}
return fmt.Sprintf("%s %v", tmpl, value)
}
// parseValue converts a raw SCPI response string into the channel's data type.
func parseValue(t datasource.DataType, resp string) (any, error) {
resp = strings.TrimSpace(resp)
switch t {
case datasource.TypeString:
return resp, nil
case datasource.TypeInt64:
// Accept "12", "12.0", or scientific notation by going through float.
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse int %q: %w", resp, err)
}
return int64(f), nil
case datasource.TypeBool:
switch strings.ToUpper(resp) {
case "1", "ON", "TRUE":
return true, nil
case "0", "OFF", "FALSE":
return false, nil
}
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse bool %q: %w", resp, err)
}
return f != 0, nil
default:
f, err := strconv.ParseFloat(resp, 64)
if err != nil {
return nil, fmt.Errorf("scpi: parse float %q: %w", resp, err)
}
return f, nil
}
}
+267
View File
@@ -0,0 +1,267 @@
package scpi
import (
"bufio"
"context"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/uopi/uopi/internal/datasource"
)
// mockInstrument is an in-process line-based SCPI server. It answers queries
// from a fixed table and records commands that produce no response (writes).
type mockInstrument struct {
ln net.Listener
mu sync.Mutex
answers map[string]string // query → response
writes []string
}
func newMockInstrument(t *testing.T) *mockInstrument {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
m := &mockInstrument{ln: ln, answers: map[string]string{}}
go m.serve()
t.Cleanup(func() { ln.Close() })
return m
}
func (m *mockInstrument) addr() string { return m.ln.Addr().String() }
func (m *mockInstrument) setAnswer(q, a string) {
m.mu.Lock()
m.answers[q] = a
m.mu.Unlock()
}
func (m *mockInstrument) writeLog() []string {
m.mu.Lock()
defer m.mu.Unlock()
return append([]string(nil), m.writes...)
}
func (m *mockInstrument) serve() {
for {
conn, err := m.ln.Accept()
if err != nil {
return
}
go m.handle(conn)
}
}
func (m *mockInstrument) handle(conn net.Conn) {
defer conn.Close()
br := bufio.NewReader(conn)
for {
line, err := br.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimRight(line, "\r\n")
m.mu.Lock()
if strings.HasSuffix(cmd, "?") {
resp, ok := m.answers[cmd]
if !ok {
resp = "0"
}
m.mu.Unlock()
conn.Write([]byte(resp + "\n"))
continue
}
m.writes = append(m.writes, cmd)
m.mu.Unlock()
}
}
func testCfg(addr string) Config {
return Config{
Enabled: true,
PollIntervalMs: 20,
Instruments: []Instrument{{
Name: "dmm",
Address: addr,
Channels: []Channel{
{Name: "volt", Query: "MEAS:VOLT?", WriteCmd: "VOLT %v", Type: "float", Unit: "V"},
{Name: "id", Query: "*IDN?", Type: "string"},
{Name: "n", Query: "COUNT?", Type: "int"},
{Name: "out", Query: "OUTP?", WriteCmd: "OUTP", Type: "bool"},
},
}},
}
}
func TestQueryTypes(t *testing.T) {
srv := newMockInstrument(t)
srv.setAnswer("MEAS:VOLT?", "12.34")
srv.setAnswer("*IDN?", "ACME,DMM,1,2.0")
srv.setAnswer("COUNT?", "7")
srv.setAnswer("OUTP?", "ON")
s, err := New(testCfg(srv.addr()))
if err != nil {
t.Fatalf("New: %v", err)
}
defer s.Close()
v, err := s.readSignal(s.signals["dmm:volt"])
if err != nil {
t.Fatalf("read volt: %v", err)
}
if f, ok := v.Data.(float64); !ok || f < 12.33 || f > 12.35 {
t.Errorf("volt = %v (%T), want 12.34", v.Data, v.Data)
}
id, _ := s.readSignal(s.signals["dmm:id"])
if id.Data != "ACME,DMM,1,2.0" {
t.Errorf("id = %v", id.Data)
}
n, _ := s.readSignal(s.signals["dmm:n"])
if iv, ok := n.Data.(int64); !ok || iv != 7 {
t.Errorf("n = %v (%T), want 7", n.Data, n.Data)
}
out, _ := s.readSignal(s.signals["dmm:out"])
if b, ok := out.Data.(bool); !ok || !b {
t.Errorf("out = %v, want true", out.Data)
}
}
func TestWrite(t *testing.T) {
srv := newMockInstrument(t)
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx := context.Background()
if err := s.Write(ctx, "dmm:volt", 3.3); err != nil {
t.Fatalf("write volt: %v", err)
}
// bool write uses a template with no verb → "OUTP <val>".
if err := s.Write(ctx, "dmm:out", true); err != nil {
t.Fatalf("write out: %v", err)
}
// Give the server a moment to record both writes.
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(srv.writeLog()) >= 2 {
break
}
time.Sleep(5 * time.Millisecond)
}
got := srv.writeLog()
if len(got) != 2 || got[0] != "VOLT 3.3" || got[1] != "OUTP true" {
t.Errorf("writes = %v, want [VOLT 3.3, OUTP true]", got)
}
}
func TestWriteErrors(t *testing.T) {
srv := newMockInstrument(t)
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx := context.Background()
if err := s.Write(ctx, "dmm:missing", 1); err != datasource.ErrNotFound {
t.Errorf("missing = %v, want ErrNotFound", err)
}
if err := s.Write(ctx, "dmm:id", 1); err != datasource.ErrNotWritable {
t.Errorf("read-only = %v, want ErrNotWritable", err)
}
}
func TestSubscribe(t *testing.T) {
srv := newMockInstrument(t)
srv.setAnswer("MEAS:VOLT?", "5.0")
s, _ := New(testCfg(srv.addr()))
defer s.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 4)
stop, err := s.Subscribe(ctx, "dmm:volt", ch)
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityGood {
t.Errorf("quality = %v", v.Quality)
}
if f, ok := v.Data.(float64); !ok || f != 5.0 {
t.Errorf("value = %v, want 5.0", v.Data)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestSubscribeBadQuality(t *testing.T) {
cfg := testCfg("127.0.0.1:1") // refused
s, _ := New(cfg)
defer s.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan datasource.Value, 1)
stop, _ := s.Subscribe(ctx, "dmm:volt", ch)
defer stop()
select {
case v := <-ch:
if v.Quality != datasource.QualityBad {
t.Errorf("quality = %v, want bad", v.Quality)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out")
}
}
func TestValidation(t *testing.T) {
if _, err := New(Config{Instruments: []Instrument{{Name: "", Address: "x"}}}); err == nil {
t.Error("want error for missing name")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
t.Error("want error for duplicate instrument")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "vxi11"}}}); err == nil {
t.Error("want error for unimplemented vxi11 transport")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "bogus"}}}); err == nil {
t.Error("want error for unknown transport")
}
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Channels: []Channel{{Name: "c"}}}}}); err == nil {
t.Error("want error for channel without query")
}
}
func TestFormatWrite(t *testing.T) {
if got := formatWrite("VOLT %v", 3.3); got != "VOLT 3.3" {
t.Errorf("verb template = %q", got)
}
if got := formatWrite("OUTP", true); got != "OUTP true" {
t.Errorf("plain template = %q", got)
}
}
func TestParseValue(t *testing.T) {
if v, _ := parseValue(datasource.TypeBool, "OFF"); v != false {
t.Errorf("OFF = %v, want false", v)
}
if v, _ := parseValue(datasource.TypeBool, "2.0"); v != true {
t.Errorf("2.0 bool = %v, want true", v)
}
if _, err := parseValue(datasource.TypeFloat64, "notnum"); err == nil {
t.Error("want parse error for non-numeric float")
}
}
+107
View File
@@ -0,0 +1,107 @@
package scpi
import (
"bufio"
"fmt"
"net"
"strings"
"sync"
"time"
)
// transport is the request/response channel to an instrument. The raw-socket
// implementation below speaks line-oriented SCPI over TCP (the common
// "SCPI raw" / port 5025 convention). The interface is kept narrow so a VXI-11
// (ONC-RPC) transport can be added later without touching the data source.
type transport interface {
// query sends cmd and returns the instrument's single-line response.
query(cmd string) (string, error)
// write sends cmd and does not wait for a response.
write(cmd string) error
close()
}
// rawSocket is a line-based SCPI transport over a single TCP connection. The
// connection is dialled lazily and dropped on any I/O error so the next call
// reconnects. Calls are serialised by mu because SCPI is request/response.
type rawSocket struct {
addr string
timeout time.Duration
terminator string
mu sync.Mutex
conn net.Conn
br *bufio.Reader
}
func newRawSocket(addr string, timeout time.Duration, terminator string) *rawSocket {
if timeout <= 0 {
timeout = 3 * time.Second
}
if terminator == "" {
terminator = "\n"
}
return &rawSocket{addr: addr, timeout: timeout, terminator: terminator}
}
func (s *rawSocket) dialLocked() error {
if s.conn != nil {
return nil
}
conn, err := net.DialTimeout("tcp", s.addr, s.timeout)
if err != nil {
return fmt.Errorf("scpi: dial %s: %w", s.addr, err)
}
s.conn = conn
s.br = bufio.NewReader(conn)
return nil
}
func (s *rawSocket) closeLocked() {
if s.conn != nil {
_ = s.conn.Close()
s.conn = nil
s.br = nil
}
}
func (s *rawSocket) close() {
s.mu.Lock()
s.closeLocked()
s.mu.Unlock()
}
func (s *rawSocket) sendLocked(cmd string) error {
_ = s.conn.SetDeadline(time.Now().Add(s.timeout))
if _, err := s.conn.Write([]byte(cmd + s.terminator)); err != nil {
s.closeLocked()
return fmt.Errorf("scpi: write: %w", err)
}
return nil
}
func (s *rawSocket) write(cmd string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.dialLocked(); err != nil {
return err
}
return s.sendLocked(cmd)
}
func (s *rawSocket) query(cmd string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.dialLocked(); err != nil {
return "", err
}
if err := s.sendLocked(cmd); err != nil {
return "", err
}
line, err := s.br.ReadString('\n')
if err != nil {
s.closeLocked()
return "", fmt.Errorf("scpi: read: %w", err)
}
return strings.TrimRight(line, "\r\n"), nil
}
@@ -15,11 +15,13 @@ type SignalDef struct {
// Visibility controls who sees this signal in the signal tree:
// "global" — listed in every panel's edit mode
// "user" — listed in every panel owned by Owner
// "group" — listed for Owner and members of any group in Groups
// "panel" — listed only when editing the bound Panel
// An empty value is treated as "global" for backward compatibility with
// definitions created before this field existed.
Visibility string `json:"visibility,omitempty"`
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
Groups []string `json:"groups,omitempty"` // groups for "group" visibility
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
// Version and Tag implement git-style revisioning. Version is bumped on
@@ -49,6 +51,17 @@ type NodeDef struct {
type Graph struct {
Nodes []GraphNode `json:"nodes"`
Output string `json:"output"` // id of the output node
// Groups is cosmetic editor metadata (node grouping/collapsing). It has no
// effect on evaluation; stored and round-tripped so the editor keeps shape.
Groups []NodeGroup `json:"groups,omitempty"`
}
// NodeGroup is a cosmetic, editor-side grouping of graph nodes (no eval effect).
type NodeGroup struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Members []string `json:"members"`
Collapsed bool `json:"collapsed,omitempty"`
}
// GraphNode is one node in a Graph.
+16 -3
View File
@@ -52,7 +52,20 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
// - stateful ops (filters) and lua are scalar-only; an array input errors,
// since their per-evaluation state cannot be split across array lanes.
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
vals := make(map[string]dsp.Sample, len(rg.order))
vals, err := rg.evalSampleTrace(sourceVals)
if err != nil {
return dsp.Sample{}, err
}
return vals[rg.outputID], nil
}
// evalSampleTrace runs a full forward pass like evalSample but returns the value
// computed for *every* node (sources, ops and the output), keyed by node id. It
// is used by the editor's live/debug trace to show each node's current value.
// On an op error it returns the values computed so far together with the error,
// so partial results can still be displayed.
func (rg *runtimeGraph) evalSampleTrace(sourceVals map[string]dsp.Sample) (map[string]dsp.Sample, error) {
vals := make(map[string]dsp.Sample, len(rg.order)+len(sourceVals))
for id, v := range sourceVals {
vals[id] = v
}
@@ -65,7 +78,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample
}
r, err := evalOp(n, in)
if err != nil {
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
return vals, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
}
vals[n.id] = r
case "output":
@@ -74,7 +87,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample
}
}
}
return vals[rg.outputID], nil
return vals, nil
}
// evalOp runs a single op node over its Sample inputs, choosing the right
@@ -462,6 +462,65 @@ func outTypeOf(st *signalState) dsp.ValType {
return st.rg.outType
}
// TraceNode is a single node's computed value in a debug trace.
type TraceNode struct {
Value any `json:"value"`
Type string `json:"type"` // "scalar" | "array"
Approx bool `json:"approx,omitempty"`
}
// TraceResult is the per-node outcome of a single-shot evaluation of an
// (unsaved) synthetic graph, used by the editor's live/debug overlay.
type TraceResult struct {
Nodes map[string]TraceNode `json:"nodes"`
Error string `json:"error,omitempty"`
}
// Trace compiles def and evaluates it once with fresh state, returning the value
// computed for every node. Source node values are obtained via read(ds, name)
// (typically a broker ReadNow snapshot); a source that cannot be read defaults to
// scalar 0. Stateful ops are flagged Approx since their true running value
// depends on accumulated state this single-shot pass does not have. A per-node
// evaluation error is returned in Error with the partial values still populated.
func (s *Synthetic) Trace(def SignalDef, read func(ds, name string) (any, error)) (TraceResult, error) {
rg, err := compileGraph(def)
if err != nil {
return TraceResult{}, err
}
sourceVals := make(map[string]dsp.Sample, len(rg.sources))
for _, src := range rg.sources {
raw, rerr := read(src.ref.DS, src.ref.Name)
if rerr != nil || raw == nil {
sourceVals[src.id] = dsp.Scalar(0)
continue
}
sourceVals[src.id] = toSample(raw)
}
vals, evalErr := rg.evalSampleTrace(sourceVals)
opType := make(map[string]string, len(rg.order))
for _, n := range rg.order {
if n.kind == "op" && n.op != nil {
opType[n.id] = n.op.Type()
}
}
res := TraceResult{Nodes: make(map[string]TraceNode, len(vals))}
for id, sample := range vals {
tn := TraceNode{Value: sample.AsAny(), Type: "scalar"}
if sample.IsArray {
tn.Type = "array"
}
if ot, ok := opType[id]; ok && dsp.IsStateful(ot) {
tn.Approx = true
}
res.Nodes[id] = tn
}
if evalErr != nil {
res.Error = evalErr.Error()
}
return res, nil
}
// toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become
// array Samples (waveforms), everything else a scalar Sample.
func toSample(v any) dsp.Sample {
+28
View File
@@ -0,0 +1,28 @@
package datasource
import (
"context"
"testing"
)
// TestWithUserAndUserFrom covers the context identity round-trip, including the
// empty-user passthrough and the missing-identity fallback.
func TestWithUserAndUserFrom(t *testing.T) {
base := context.Background()
// No identity present.
if u, ok := UserFrom(base); ok || u != "" {
t.Errorf("UserFrom(empty) = %q,%v want \"\",false", u, ok)
}
// Empty user must not attach a value.
if ctx := WithUser(base, ""); ctx != base {
t.Error("WithUser with empty user should return the original context")
}
// Real identity round-trips.
ctx := WithUser(base, "alice")
if u, ok := UserFrom(ctx); !ok || u != "alice" {
t.Errorf("UserFrom = %q,%v want alice,true", u, ok)
}
}
+145
View File
@@ -0,0 +1,145 @@
package dsp
import (
"math"
"testing"
)
// TestExprFunctions exercises every built-in function branch of parseCall plus
// the two-argument forms and the right-associative power operator.
func TestExprFunctions(t *testing.T) {
st := map[string]any{}
cases := []struct {
expr string
inputs []float64
want float64
}{
{"exp(a)", []float64{1}, math.E},
{"log(a)", []float64{math.E}, 1},
{"ln(a)", []float64{math.E}, 1},
{"log2(a)", []float64{8}, 3},
{"log10(a)", []float64{1000}, 3},
{"sqrt(a)", []float64{9}, 3},
{"abs(a)", []float64{-4}, 4},
{"sin(a)", []float64{0}, 0},
{"cos(a)", []float64{0}, 1},
{"tan(a)", []float64{0}, 0},
{"asin(a)", []float64{1}, math.Pi / 2},
{"acos(a)", []float64{1}, 0},
{"atan(a)", []float64{1}, math.Pi / 4},
{"atan2(a, b)", []float64{1, 1}, math.Pi / 4},
{"pow(a, b)", []float64{2, 10}, 1024},
{"floor(a)", []float64{2.9}, 2},
{"ceil(a)", []float64{2.1}, 3},
{"round(a)", []float64{2.5}, 3},
{"min(a, b)", []float64{3, 7}, 3},
{"max(a, b)", []float64{3, 7}, 7},
{"a ^ b", []float64{2, 3}, 8},
{"2 ^ 3 ^ 2", []float64{}, 512}, // right-associative: 2^(3^2)
{"-a ^ 2", []float64{3}, -9}, // unary minus binds outside power
}
for _, tc := range cases {
t.Run(tc.expr, func(t *testing.T) {
n := &ExprNode{Expr: tc.expr}
got, err := n.Process(tc.inputs, st)
if err != nil {
t.Fatalf("Process(%q): %v", tc.expr, err)
}
if math.Abs(got-tc.want) > 1e-9 {
t.Errorf("Process(%q) = %v, want %v", tc.expr, got, tc.want)
}
})
}
}
// TestExprFunctionErrors covers the error branches of parseCall/parseFactor.
func TestExprFunctionErrors(t *testing.T) {
st := map[string]any{}
cases := []string{
"bogus(a)", // unknown function
"sqrt(a", // missing ')'
"sqrt(", // empty / unexpected end inside call
"@", // unexpected character
"(a + 1", // missing closing parenthesis
"1.2.3", // invalid number
}
for _, expr := range cases {
t.Run(expr, func(t *testing.T) {
n := &ExprNode{Expr: expr}
if _, err := n.Process([]float64{1}, st); err == nil {
t.Errorf("Process(%q): want error", expr)
}
})
}
}
// TestArrayNodeScalarAdapters covers the legacy scalar Node interface
// (Type + Process) on the array nodes, which the array-path tests skip.
func TestArrayNodeScalarAdapters(t *testing.T) {
st := map[string]any{}
// Reductions: Process treats its float64 inputs as a single-element array,
// so each reduction over one value returns that value.
reductions := []struct {
node ArrayNode
typ string
}{
{&SumNode{}, "sum"},
{&MeanNode{}, "mean"},
{&MinNode{}, "min"},
{&MaxNode{}, "max"},
{&IndexNode{I: 0}, "index"},
}
for _, r := range reductions {
t.Run(r.typ, func(t *testing.T) {
if r.node.Type() != r.typ {
t.Errorf("Type() = %q, want %q", r.node.Type(), r.typ)
}
got, err := r.node.Process([]float64{42}, st)
if err != nil {
t.Fatalf("Process: %v", err)
}
if got != 42 {
t.Errorf("Process = %v, want 42", got)
}
})
}
// LengthNode over a single scalar input → length 1.
ln := &LengthNode{}
if ln.Type() != "length" {
t.Errorf("LengthNode.Type() = %q", ln.Type())
}
if got, err := ln.Process([]float64{7}, st); err != nil || got != 1 {
t.Errorf("LengthNode.Process = %v, %v; want 1", got, err)
}
// SliceNode.Process returns the first element of the resulting slice.
sn := &SliceNode{Start: 0, End: 0}
if sn.Type() != "slice" {
t.Errorf("SliceNode.Type() = %q", sn.Type())
}
if got, err := sn.Process([]float64{5}, st); err != nil || got != 5 {
t.Errorf("SliceNode.Process = %v, %v; want 5", got, err)
}
// FFTNode.Process returns the first magnitude bin (DC term = the value).
fn := &FFTNode{}
if fn.Type() != "fft" {
t.Errorf("FFTNode.Type() = %q", fn.Type())
}
if got, err := fn.Process([]float64{3}, st); err != nil || math.Abs(got-3) > 1e-9 {
t.Errorf("FFTNode.Process = %v, %v; want 3", got, err)
}
}
// TestArrayNodeProcessErrors covers the error propagation through the scalar
// Process adapters.
func TestArrayNodeProcessErrors(t *testing.T) {
st := map[string]any{}
// IndexNode with an out-of-range index propagates the reduction error.
n := &IndexNode{I: 5}
if _, err := n.Process([]float64{1}, st); err == nil {
t.Error("IndexNode.Process out of range: want error")
}
}
+12
View File
@@ -22,8 +22,20 @@ var (
"moving_average": true, "rms": true, "lowpass": true,
"derivative": true, "integrate": true, "lua": true,
}
// statefulOps accumulate state across evaluations, so a single-shot trace
// (fresh state) only approximates their true running value. lua is included
// because a script may persist state in its state table.
statefulOps = map[string]bool{
"moving_average": true, "rms": true, "lowpass": true,
"derivative": true, "integrate": true, "lua": true,
}
)
// IsStateful reports whether an op accumulates state across evaluations. The
// editor's live/debug trace flags such nodes as "approx" since it evaluates a
// single tick with fresh state.
func IsStateful(op string) bool { return statefulOps[op] }
// OpOutputType reports the output ValType of an op given its input types, and
// an error if the inputs are definitely incompatible with the op. Inputs may be
// ValUnknown (a source whose real type is not yet known at compile time); such
+195
View File
@@ -0,0 +1,195 @@
// Package ldapauth validates a username/password pair against an LDAP directory
// using the standard "search then bind" pattern, exactly as an SSSD/LDAP client
// would: connect to the directory, find the user's entry under the configured
// search base, then attempt a bind as that entry's DN with the supplied password.
//
// It is a pure-Go alternative to the PAM backend (internal/pamauth): because it
// speaks LDAP over the wire with no cgo, uopi keeps its fully-static
// (CGO_ENABLED=0) release binary while still authenticating against the same
// directory the host logs in with. It feeds the same HTTP Basic pipeline
// (internal/server/basicauth.go) as PAM.
//
// Unlike PAM it only verifies the password; it does not run the rest of the PAM
// stack (account expiry, access.conf, MFA). For a monitoring HMI that is normally
// sufficient.
package ldapauth
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"os"
"time"
"github.com/go-ldap/ldap/v3"
)
// Config configures the LDAP authenticator. URIs and SearchBase are required; the
// remaining fields mirror SSSD defaults so an unconfigured directory (anonymous
// search, RFC2307 schema) works out of the box.
type Config struct {
// URIs are the directory endpoints, tried in order until one connects, e.g.
// "ldaps://ldap.example.com" or "ldap://ldap.example.com". Mirrors SSSD's
// ldap_uri.
URIs []string
// SearchBase is the subtree under which user entries are searched. Mirrors
// SSSD's ldap_search_base.
SearchBase string
// UserAttr is the attribute matched against the login name. Empty defaults to
// "uid" (SSSD's ldap_user_name default for RFC2307).
UserAttr string
// UserObjectClass restricts the search to this objectClass. Empty defaults to
// "posixAccount" (SSSD's ldap_user_object_class default).
UserObjectClass string
// BindDN / BindPassword optionally authenticate the *search* (service
// account). Empty BindDN performs an anonymous search, matching a directory
// configured without ldap_default_bind_dn.
BindDN string
BindPassword string
// StartTLS upgrades a plain ldap:// connection to TLS before any credentials
// are sent. Ignored for ldaps:// (already TLS).
StartTLS bool
// CACertFile is an optional PEM file of CA certs to trust for the TLS
// connection (for a directory using a private CA).
CACertFile string
// InsecureSkipVerify disables TLS certificate verification. Insecure; use only
// for testing against a self-signed directory.
InsecureSkipVerify bool
// Timeout bounds each connection attempt. Zero defaults to 10s.
Timeout time.Duration
}
// ErrInvalidCredentials is returned when the directory rejects the user's bind.
var ErrInvalidCredentials = errors.New("ldap: invalid credentials")
// Authenticator validates credentials against a fixed directory configuration.
// It is safe for concurrent use: each Authenticate call opens and closes its own
// connection.
type Authenticator struct {
cfg Config
tlsConfig *tls.Config
}
// New validates cfg and returns an Authenticator. It fails fast on missing
// required fields or an unreadable CA file so misconfiguration surfaces at
// startup rather than on the first login.
func New(cfg Config) (*Authenticator, error) {
if len(cfg.URIs) == 0 {
return nil, errors.New("ldap: at least one uri is required")
}
if cfg.SearchBase == "" {
return nil, errors.New("ldap: search_base is required")
}
if cfg.UserAttr == "" {
cfg.UserAttr = "uid"
}
if cfg.UserObjectClass == "" {
cfg.UserObjectClass = "posixAccount"
}
if cfg.Timeout <= 0 {
cfg.Timeout = 10 * time.Second
}
tlsConfig := &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}
if cfg.CACertFile != "" {
pem, err := os.ReadFile(cfg.CACertFile)
if err != nil {
return nil, fmt.Errorf("ldap: reading ca_cert: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("ldap: ca_cert %q contained no certificates", cfg.CACertFile)
}
tlsConfig.RootCAs = pool
}
return &Authenticator{cfg: cfg, tlsConfig: tlsConfig}, nil
}
// Authenticate verifies username/password against the directory. It returns nil
// on success, ErrInvalidCredentials when the directory rejects the bind, or
// another error on connection/search failure.
func (a *Authenticator) Authenticate(username, password string) error {
// A bind with a non-empty DN but an empty password is an "unauthenticated
// bind" that many servers accept as success — which would let anyone in with a
// blank password. Reject empty passwords before we ever bind.
if password == "" {
return ErrInvalidCredentials
}
conn, err := a.dial()
if err != nil {
return err
}
defer conn.Close()
// Bind for the search: service account if configured, else anonymous.
if a.cfg.BindDN != "" {
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPassword); err != nil {
return fmt.Errorf("ldap: search bind failed: %w", err)
}
}
// Locate the user's entry. The login name is escaped to prevent LDAP filter
// injection.
filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
ldap.EscapeFilter(a.cfg.UserObjectClass),
a.cfg.UserAttr,
ldap.EscapeFilter(username))
req := ldap.NewSearchRequest(
a.cfg.SearchBase,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, int(a.cfg.Timeout.Seconds()), false,
filter,
[]string{"dn"}, nil,
)
res, err := conn.Search(req)
if err != nil {
return fmt.Errorf("ldap: search failed: %w", err)
}
if len(res.Entries) == 0 {
return ErrInvalidCredentials // unknown user — do not distinguish from bad password
}
if len(res.Entries) > 1 {
return fmt.Errorf("ldap: %q matched %d entries; refusing ambiguous bind", username, len(res.Entries))
}
userDN := res.Entries[0].DN
// Verify the password by binding as the user. Use a fresh connection so the
// search identity is fully dropped first.
userConn, err := a.dial()
if err != nil {
return err
}
defer userConn.Close()
if err := userConn.Bind(userDN, password); err != nil {
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
return ErrInvalidCredentials
}
return fmt.Errorf("ldap: user bind failed: %w", err)
}
return nil
}
// dial connects to the first reachable URI and applies StartTLS when requested.
func (a *Authenticator) dial() (*ldap.Conn, error) {
var lastErr error
for _, uri := range a.cfg.URIs {
conn, err := ldap.DialURL(uri, ldap.DialWithTLSConfig(a.tlsConfig))
if err != nil {
lastErr = err
continue
}
conn.SetTimeout(a.cfg.Timeout)
if a.cfg.StartTLS {
if err := conn.StartTLS(a.tlsConfig); err != nil {
conn.Close()
lastErr = fmt.Errorf("ldap: starttls on %q: %w", uri, err)
continue
}
}
return conn, nil
}
return nil, fmt.Errorf("ldap: could not connect to any uri: %w", lastErr)
}
+60
View File
@@ -0,0 +1,60 @@
package ldapauth
import (
"errors"
"os"
"path/filepath"
"testing"
)
func TestNewRequiresURIAndBase(t *testing.T) {
if _, err := New(Config{SearchBase: "dc=x"}); err == nil {
t.Fatal("want error for missing uri")
}
if _, err := New(Config{URIs: []string{"ldap://x"}}); err == nil {
t.Fatal("want error for missing search_base")
}
}
func TestNewAppliesSSSDDefaults(t *testing.T) {
a, err := New(Config{URIs: []string{"ldap://x"}, SearchBase: "dc=x"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if a.cfg.UserAttr != "uid" {
t.Errorf("UserAttr default = %q, want uid", a.cfg.UserAttr)
}
if a.cfg.UserObjectClass != "posixAccount" {
t.Errorf("UserObjectClass default = %q, want posixAccount", a.cfg.UserObjectClass)
}
if a.cfg.Timeout <= 0 {
t.Errorf("Timeout default not applied: %v", a.cfg.Timeout)
}
}
// Empty passwords must be rejected before any bind: a non-empty DN + empty
// password is an "unauthenticated bind" many servers accept as success.
func TestAuthenticateRejectsEmptyPasswordWithoutDialing(t *testing.T) {
// An unreachable URI guarantees the test fails loudly if it ever tries to dial.
a, err := New(Config{URIs: []string{"ldap://127.0.0.1:1"}, SearchBase: "dc=x"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := a.Authenticate("alice", ""); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("want ErrInvalidCredentials for empty password, got %v", err)
}
}
func TestNewRejectsBadCACert(t *testing.T) {
dir := t.TempDir()
bad := filepath.Join(dir, "ca.pem")
if err := os.WriteFile(bad, []byte("not a certificate"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: bad}); err == nil {
t.Fatal("want error for CA file with no certificates")
}
if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: filepath.Join(dir, "missing.pem")}); err == nil {
t.Fatal("want error for missing CA file")
}
}
+24
View File
@@ -38,6 +38,30 @@ func IncWrites() { writeOps.Add(1) }
// IncHistoryReqs increments the history request counter.
func IncHistoryReqs() { historyReqs.Add(1) }
// Stats is a point-in-time snapshot of the in-process counters, for rendering
// as JSON in the admin pane (the Prometheus Handler renders the same data as
// text).
type Stats struct {
UptimeSeconds float64 `json:"uptimeSeconds"`
WsConnections int64 `json:"wsConnections"`
MsgIn int64 `json:"msgIn"`
MsgOut int64 `json:"msgOut"`
WriteOps int64 `json:"writeOps"`
HistoryReqs int64 `json:"historyReqs"`
}
// Snapshot returns the current values of all counters and gauges.
func Snapshot() Stats {
return Stats{
UptimeSeconds: time.Since(startTime).Seconds(),
WsConnections: wsConns.Load(),
MsgIn: msgIn.Load(),
MsgOut: msgOut.Load(),
WriteOps: writeOps.Load(),
HistoryReqs: historyReqs.Load(),
}
}
// Handler returns an http.HandlerFunc that renders Prometheus-format metrics.
// activeSubs is called each request to read the current number of unique signal
// subscriptions from the broker; pass nil to omit the metric.
+106
View File
@@ -0,0 +1,106 @@
//go:build pam
// Package pamauth authenticates a username/password pair against the host's PAM
// stack (/etc/pam.d/<service>). It is the backend for uopi's built-in HTTP Basic
// authentication: because the uopi host is typically already an SSSD/LDAP client,
// validating through PAM reuses the exact same login path as `login`/`ssh`
// (pam_sss → the site directory) without uopi needing any directory schema.
//
// This file is the real implementation, compiled only with the `pam` build tag
// (which also requires cgo + libpam). The default fully-static CGO_ENABLED=0
// build uses stub.go instead, where Authenticate reports PAM is unavailable.
package pamauth
/*
#cgo LDFLAGS: -lpam
#include <security/pam_appl.h>
#include <stdlib.h>
#include <string.h>
// uopiPamConv answers every password-style PAM prompt with the password passed
// through appdata_ptr. Informational/error messages get a NULL response. The PAM
// library takes ownership of the returned responses and frees them.
static int uopiPamConv(int num_msg, const struct pam_message **msg,
struct pam_response **resp, void *appdata_ptr) {
if (num_msg <= 0 || num_msg > PAM_MAX_NUM_MSG) {
return PAM_CONV_ERR;
}
struct pam_response *r = calloc((size_t)num_msg, sizeof(struct pam_response));
if (r == NULL) {
return PAM_BUF_ERR;
}
for (int i = 0; i < num_msg; i++) {
int style = msg[i]->msg_style;
if (style == PAM_PROMPT_ECHO_OFF || style == PAM_PROMPT_ECHO_ON) {
r[i].resp = strdup((const char *)appdata_ptr);
if (r[i].resp == NULL) {
for (int j = 0; j < i; j++) {
free(r[j].resp);
}
free(r);
return PAM_BUF_ERR;
}
}
r[i].resp_retcode = 0;
}
*resp = r;
return PAM_SUCCESS;
}
// uopiPamAuth runs authentication + account management for service/user using
// pass. Returns PAM_SUCCESS or the failing PAM error code.
static int uopiPamAuth(const char *service, const char *user, char *pass) {
struct pam_conv conv;
conv.conv = uopiPamConv;
conv.appdata_ptr = (void *)pass;
pam_handle_t *pamh = NULL;
int ret = pam_start(service, user, &conv, &pamh);
if (ret != PAM_SUCCESS) {
return ret;
}
ret = pam_authenticate(pamh, PAM_DISALLOW_NULL_AUTHTOK);
if (ret == PAM_SUCCESS) {
ret = pam_acct_mgmt(pamh, PAM_DISALLOW_NULL_AUTHTOK);
}
pam_end(pamh, ret);
return ret;
}
// uopiStrerror maps a PAM error code to a human-readable string. Linux-PAM
// ignores the handle, so NULL is fine after pam_end.
static const char *uopiStrerror(int code) {
return pam_strerror(NULL, code);
}
*/
import "C"
import (
"errors"
"fmt"
"unsafe"
)
// Available reports whether this build includes PAM support. True here.
const Available = true
// ErrUnavailable is returned by Authenticate in builds without PAM support. It
// is declared in both build variants so callers can compare against it.
var ErrUnavailable = errors.New("pamauth: PAM support not compiled in")
// Authenticate verifies username/password against the named PAM service
// (/etc/pam.d/<service>). It returns nil on success or an error describing the
// PAM failure. It is safe for concurrent use.
func Authenticate(service, username, password string) error {
cService := C.CString(service)
cUser := C.CString(username)
cPass := C.CString(password)
defer C.free(unsafe.Pointer(cService))
defer C.free(unsafe.Pointer(cUser))
defer C.free(unsafe.Pointer(cPass))
if ret := C.uopiPamAuth(cService, cUser, cPass); ret != C.PAM_SUCCESS {
return fmt.Errorf("pam: %s", C.GoString(C.uopiStrerror(ret)))
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !pam
// Package pamauth authenticates a username/password pair against the host's PAM
// stack. This is the stub compiled into the default fully-static
// (CGO_ENABLED=0) build, which has no PAM/libpam linkage: Authenticate always
// reports PAM is unavailable. Build with `make backend-pam` (CGO_ENABLED=1
// -tags pam) to get the real implementation in pam.go.
package pamauth
import "errors"
// Available reports whether this build includes PAM support. False here.
const Available = false
// ErrUnavailable is returned by Authenticate because this build lacks PAM.
var ErrUnavailable = errors.New("pamauth: PAM support not compiled in (rebuild with: make backend-pam)")
// Authenticate always fails in the non-PAM build.
func Authenticate(service, username, password string) error {
return ErrUnavailable
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !pam
package pamauth
import (
"errors"
"testing"
)
// TestStubUnavailable verifies the default (non-PAM) build reports PAM as
// unavailable and Authenticate always fails with ErrUnavailable.
func TestStubUnavailable(t *testing.T) {
if Available {
t.Error("Available should be false in the non-PAM build")
}
if err := Authenticate("login", "alice", "secret"); !errors.Is(err, ErrUnavailable) {
t.Errorf("Authenticate = %v, want ErrUnavailable", err)
}
}
+80
View File
@@ -0,0 +1,80 @@
package panelacl
import "testing"
// TestPermString covers the Perm→token rendering.
func TestPermString(t *testing.T) {
cases := map[Perm]string{PermNone: "none", PermRead: "read", PermWrite: "write"}
for p, want := range cases {
if got := p.String(); got != want {
t.Errorf("Perm(%d).String() = %q, want %q", p, got, want)
}
}
}
// TestPlaceAndDeletePanel covers PlacePanel (organizational-only record that
// stays open) and DeletePanel (present + absent).
func TestPlaceAndDeletePanel(t *testing.T) {
s := newStore(t)
if _, err := s.CreateFolder("f1", "Folder 1", "", "alice"); err != nil {
t.Fatal(err)
}
// Placing a legacy panel into a folder must not lock it (no owner record).
if err := s.PlacePanel("legacy", "f1", 2.5); err != nil {
t.Fatalf("PlacePanel: %v", err)
}
if got := s.PanelPerm("legacy", "bob", nil); got != PermWrite {
t.Errorf("placed legacy panel perm = %v, want write", got)
}
// DeletePanel on a missing record is a no-op success.
if err := s.DeletePanel("never"); err != nil {
t.Errorf("DeletePanel missing: %v", err)
}
// DeletePanel on the placed record removes it.
if err := s.DeletePanel("legacy"); err != nil {
t.Fatalf("DeletePanel: %v", err)
}
}
// TestFoldersAndGetFolder covers the folder accessors, FolderPerm on an owner
// vs a stranger, and reload from the persisted index.
func TestFoldersAndGetFolder(t *testing.T) {
dir := t.TempDir()
s, err := New(dir)
if err != nil {
t.Fatal("New:", err)
}
if _, err := s.CreateFolder("root", "Root", "", "alice"); err != nil {
t.Fatal(err)
}
all := s.Folders()
if len(all) != 1 {
t.Fatalf("Folders: want 1, got %d", len(all))
}
if f, err := s.GetFolder("root"); err != nil || f.Name != "Root" {
t.Errorf("GetFolder = %+v, %v", f, err)
}
if _, err := s.GetFolder("ghost"); err != ErrNotFound {
t.Errorf("GetFolder missing: want ErrNotFound, got %v", err)
}
// Owner has write on the folder; an unrelated user has none.
if got := s.FolderPerm("root", "alice", nil); got != PermWrite {
t.Errorf("owner FolderPerm = %v, want write", got)
}
if got := s.FolderPerm("root", "bob", nil); got != PermNone {
t.Errorf("stranger FolderPerm = %v, want none", got)
}
// Reload: a fresh Store over the same dir still sees the folder.
s2, err := New(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
if _, err := s2.GetFolder("root"); err != nil {
t.Errorf("reloaded GetFolder: %v", err)
}
}
+125
View File
@@ -0,0 +1,125 @@
package server
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"log/slog"
"net/http"
"sync"
"time"
)
// credCache memoises successful Basic-auth validations for a short TTL. Browsers
// resend the Authorization header on every request, so without this each one
// would trigger a full PAM round-trip (slow, and a brute-force/lockout risk).
// Only positive results are cached, keyed by a salted SHA-256 of user+password
// so the cache never holds a recoverable secret. A fresh random salt per process
// keeps keys from being precomputable.
type credCache struct {
mu sync.Mutex
ttl time.Duration
salt []byte
entries map[string]time.Time
}
func newCredCache(ttl time.Duration) *credCache {
salt := make([]byte, 16)
_, _ = rand.Read(salt)
return &credCache{ttl: ttl, salt: salt, entries: map[string]time.Time{}}
}
func (c *credCache) key(user, pass string) string {
h := sha256.New()
h.Write(c.salt)
h.Write([]byte(user))
h.Write([]byte{0})
h.Write([]byte(pass))
return hex.EncodeToString(h.Sum(nil))
}
// valid reports whether user/pass was validated within the TTL.
func (c *credCache) valid(user, pass string) bool {
if c.ttl <= 0 {
return false
}
k := c.key(user, pass)
c.mu.Lock()
defer c.mu.Unlock()
exp, ok := c.entries[k]
if !ok {
return false
}
if time.Now().After(exp) {
delete(c.entries, k)
return false
}
return true
}
// store records a successful validation, opportunistically pruning expired keys.
func (c *credCache) store(user, pass string) {
if c.ttl <= 0 {
return
}
k := c.key(user, pass)
now := time.Now()
c.mu.Lock()
defer c.mu.Unlock()
c.entries[k] = now.Add(c.ttl)
if len(c.entries) > 1024 {
for kk, exp := range c.entries {
if now.After(exp) {
delete(c.entries, kk)
}
}
}
}
// basicAuth wraps next with HTTP Basic authentication validated by authFn (PAM,
// see internal/pamauth). It mirrors kerberosAuth: a successful login writes the
// username into userHeader for the existing access pipeline (accessMiddleware /
// wsHandler), and any inbound value of userHeader is always discarded first so a
// client cannot spoof an identity.
//
// challenge controls the no/invalid-credentials case:
// - challenge=true (REST/page API): reply 401 + WWW-Authenticate: Basic so the
// browser prompts for credentials.
// - challenge=false (WebSocket upgrade): fall through unauthenticated; the
// session resolves to default_user. Browsers cannot show a login dialog for a
// WebSocket, but once they have cached credentials from the page's API calls
// they resend them on the upgrade, so the validated path is still taken.
func basicAuth(authFn func(user, pass string) error, userHeader, realm string, challenge bool, cache *credCache, log *slog.Logger, next http.Handler) http.Handler {
if realm == "" {
realm = "uopi"
}
challengeValue := `Basic realm="` + realm + `", charset="UTF-8"`
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Never trust a client-supplied identity header; only a validated login sets it.
r.Header.Del(userHeader)
if user, pass, ok := r.BasicAuth(); ok && user != "" {
if cache.valid(user, pass) {
r.Header.Set(userHeader, user)
next.ServeHTTP(w, r)
return
}
if err := authFn(user, pass); err == nil {
cache.store(user, pass)
r.Header.Set(userHeader, user)
next.ServeHTTP(w, r)
return
} else {
log.Warn("basic auth failed", "user", user, "err", err)
}
}
// No or invalid credentials.
if challenge {
w.Header().Set("WWW-Authenticate", challengeValue)
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r) // best-effort (WebSocket): downstream → default_user
})
}
+129
View File
@@ -0,0 +1,129 @@
package server
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
const testUserHeader = "X-Uopi-User"
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// echoUser is a terminal handler that reports the resolved identity header so a
// test can assert what basicAuth stamped.
func echoUser() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, r.Header.Get(testUserHeader))
})
}
func basicReq(t *testing.T, user, pass string, setHeader string) *http.Request {
t.Helper()
r := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
if user != "" || pass != "" {
r.SetBasicAuth(user, pass)
}
if setHeader != "" {
r.Header.Set(testUserHeader, setHeader) // a spoof attempt
}
return r
}
// authFn that accepts only alice/secret.
func aliceAuth(calls *int32) func(string, string) error {
return func(user, pass string) error {
atomic.AddInt32(calls, 1)
if user == "alice" && pass == "secret" {
return nil
}
return pamErr{}
}
}
type pamErr struct{}
func (pamErr) Error() string { return "bad credentials" }
func TestBasicAuthChallengesWithoutCredentials(t *testing.T) {
cache := newCredCache(time.Minute)
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, basicReq(t, "", "", ""))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); got == "" {
t.Fatalf("missing WWW-Authenticate challenge header")
}
}
func TestBasicAuthValidCredentialsStampUser(t *testing.T) {
cache := newCredCache(time.Minute)
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
rec := httptest.NewRecorder()
// Client also tries to spoof the identity header; it must be ignored.
h.ServeHTTP(rec, basicReq(t, "alice", "secret", "root"))
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d", rec.Code)
}
if got := rec.Body.String(); got != "alice" {
t.Fatalf("want resolved user alice, got %q", got)
}
}
func TestBasicAuthInvalidCredentialsRejected(t *testing.T) {
cache := newCredCache(time.Minute)
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, basicReq(t, "alice", "wrong", ""))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rec.Code)
}
}
func TestBasicAuthBestEffortFallsThrough(t *testing.T) {
cache := newCredCache(time.Minute)
// challenge=false (WebSocket path): no creds → pass through unauthenticated.
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", false, cache, quietLogger(), echoUser())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, basicReq(t, "", "", ""))
if rec.Code != http.StatusOK {
t.Fatalf("want 200 pass-through, got %d", rec.Code)
}
if got := rec.Body.String(); got != "" {
t.Fatalf("want empty identity, got %q", got)
}
}
func TestBasicAuthCachesSuccess(t *testing.T) {
var calls int32
cache := newCredCache(time.Minute)
h := basicAuth(aliceAuth(&calls), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
for i := 0; i < 3; i++ {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, basicReq(t, "alice", "secret", ""))
if rec.Code != http.StatusOK {
t.Fatalf("req %d: want 200, got %d", i, rec.Code)
}
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("want authFn called once (cached), got %d", got)
}
}
+187
View File
@@ -0,0 +1,187 @@
package server
import (
"encoding/json"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"github.com/uopi/uopi/internal/controllogic"
)
// DebugHub fans control-logic node-execution events out to the editors watching
// them, and owns the per-client debug subscriptions. It implements
// controllogic.DebugObserver; the engine (and simulate sandboxes) call Observe
// on every node execution.
//
// Two subscription modes share one event shape:
// - "live" — observe the running, enabled graph by its id.
// - "simulate" — dry-run an unsaved graph in a throwaway sandbox. Each
// simulate sub gets a unique route id so its events never mix with the live
// graph's (which may share the same persisted id).
//
// Routing is by event GraphID: live events carry the real graph id (only
// emitted while that id is in the engine's debugWatch set, which the hub keeps
// in sync with its live subs), simulate events carry the sandbox's unique id.
type DebugHub struct {
engine *controllogic.Engine
log *slog.Logger
seq uint64 // unique simulate route ids
mu sync.Mutex
subs map[*wsClient]*debugSub // one debug sub per client
routes map[string]map[*wsClient]struct{} // route id → watching clients
liveCount map[string]int // live-subscribed graph id → count
}
type debugSub struct {
route string // graph id (live) or unique sandbox id (simulate)
live bool // true for "live", false for "simulate"
stop func() // simulate sandbox teardown (nil for live)
}
// NewDebugHub builds an empty hub bound to the engine it observes.
func NewDebugHub(engine *controllogic.Engine, log *slog.Logger) *DebugHub {
return &DebugHub{
engine: engine,
log: log,
subs: map[*wsClient]*debugSub{},
routes: map[string]map[*wsClient]struct{}{},
liveCount: map[string]int{},
}
}
// debugOut is the client-bound JSON form of a node-execution event.
type debugOut struct {
Type string `json:"type"` // always "debugNode"
GraphID string `json:"graphId"`
NodeID string `json:"nodeId"`
Value any `json:"value"`
HasValue bool `json:"hasValue"`
TS int64 `json:"ts"`
}
// Observe implements controllogic.DebugObserver: push the event to every client
// watching its route (drop-on-full so a slow editor never stalls the engine).
func (h *DebugHub) Observe(ev controllogic.DebugEvent) {
h.mu.Lock()
watchers := h.routes[ev.GraphID]
if len(watchers) == 0 {
h.mu.Unlock()
return
}
targets := make([]*wsClient, 0, len(watchers))
for c := range watchers {
targets = append(targets, c)
}
h.mu.Unlock()
b, err := json.Marshal(debugOut{
Type: "debugNode",
GraphID: ev.GraphID,
NodeID: ev.NodeID,
Value: ev.Value,
HasValue: ev.HasValue,
TS: ev.TS,
})
if err != nil {
return
}
for _, c := range targets {
select {
case c.outCh <- b:
default:
}
}
}
// subscribeLive starts (or replaces) a client's live observation of graphID.
func (h *DebugHub) subscribeLive(c *wsClient, graphID string) {
h.unsubscribe(c)
if graphID == "" {
return
}
sub := &debugSub{route: graphID, live: true}
h.mu.Lock()
h.addRoute(c, sub)
h.liveCount[graphID]++
watch := h.snapshotWatch()
h.mu.Unlock()
h.engine.SetDebugWatch(watch)
}
// subscribeSimulate starts (or replaces) a client's dry-run of an unsaved graph.
func (h *DebugHub) subscribeSimulate(c *wsClient, g controllogic.Graph) {
h.unsubscribe(c)
id := "sim-" + strconv.FormatUint(atomic.AddUint64(&h.seq, 1), 10)
g.ID = id // route sandbox events under a unique id (never collides with live)
stop := h.engine.StartSimulate(g)
sub := &debugSub{route: id, live: false, stop: stop}
h.mu.Lock()
h.addRoute(c, sub)
h.mu.Unlock()
}
// fire forces nodeID's trigger to run in the graph the client is currently
// debugging (live or simulate), routed by that session's id. No-op if the
// client has no active debug session.
func (h *DebugHub) fire(c *wsClient, nodeID string) {
h.mu.Lock()
sub := h.subs[c]
h.mu.Unlock()
if sub == nil {
return
}
h.engine.FireTrigger(sub.route, nodeID)
}
// addRoute records sub for c; caller holds h.mu.
func (h *DebugHub) addRoute(c *wsClient, sub *debugSub) {
h.subs[c] = sub
if h.routes[sub.route] == nil {
h.routes[sub.route] = map[*wsClient]struct{}{}
}
h.routes[sub.route][c] = struct{}{}
}
// unsubscribe tears down a client's debug sub (stopping its sandbox if any) and
// refreshes the engine's watch set. Safe when the client has no sub.
func (h *DebugHub) unsubscribe(c *wsClient) {
h.mu.Lock()
sub := h.subs[c]
if sub == nil {
h.mu.Unlock()
return
}
delete(h.subs, c)
if m := h.routes[sub.route]; m != nil {
delete(m, c)
if len(m) == 0 {
delete(h.routes, sub.route)
}
}
if sub.live {
if h.liveCount[sub.route]--; h.liveCount[sub.route] <= 0 {
delete(h.liveCount, sub.route)
}
}
watch := h.snapshotWatch()
h.mu.Unlock()
if sub.stop != nil {
sub.stop()
}
h.engine.SetDebugWatch(watch)
}
// snapshotWatch builds a fresh immutable set of live-watched graph ids. Caller
// holds h.mu; the result is published to the engine which reads it lock-free.
func (h *DebugHub) snapshotWatch() map[string]bool {
w := make(map[string]bool, len(h.liveCount))
for k := range h.liveCount {
w[k] = true
}
return w
}
+223
View File
@@ -0,0 +1,223 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/uopi/uopi/internal/access"
)
// -------------------------------------------------------------------------- //
// accessMiddleware //
// -------------------------------------------------------------------------- //
// okHandler records that it ran and returns 200.
func okHandler(ran *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*ran = true
w.WriteHeader(http.StatusOK)
})
}
func TestAccessMiddlewareWriteUserPassesMutation(t *testing.T) {
// A configured policy with an operator (write) user.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "alice")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("write user POST: code=%d ran=%v, want 200/true", rec.Code, ran)
}
}
func TestAccessMiddlewareReadUserBlockedFromMutation(t *testing.T) {
// Configured policy → an unlisted user is a read-only viewer.
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "bob") // viewer
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("read-only POST: code=%d, want 403", rec.Code)
}
if ran {
t.Error("handler must not run for a forbidden mutation")
}
}
func TestAccessMiddlewareReadUserAllowedToGet(t *testing.T) {
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
req := httptest.NewRequest(http.MethodGet, "/api/v1/interfaces", nil)
req.Header.Set(testUserHeader, "bob") // viewer
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("read-only GET: code=%d ran=%v, want 200/true", rec.Code, ran)
}
}
func TestAccessMiddlewareMeAlwaysReachable(t *testing.T) {
policy := access.New("", []access.GroupSpec{
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
})
var ran bool
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
// Even a mutating method on /me is allowed through (identity discovery).
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
req.Header.Set(testUserHeader, "bob")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !ran {
t.Errorf("/me must always be reachable: code=%d ran=%v", rec.Code, ran)
}
}
func TestAccessMiddlewareStoresUserOnContext(t *testing.T) {
policy := access.New("", nil) // unconfigured → everyone write
var got string
h := accessMiddleware(policy, testUserHeader, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = access.UserFrom(r.Context())
}))
req := httptest.NewRequest(http.MethodGet, "/api/v1/foo", nil)
req.Header.Set(testUserHeader, "carol")
h.ServeHTTP(httptest.NewRecorder(), req)
if got != "carol" {
t.Errorf("context user = %q, want carol", got)
}
}
// -------------------------------------------------------------------------- //
// httpsRedirectHandler //
// -------------------------------------------------------------------------- //
func TestHTTPSRedirectHandler(t *testing.T) {
cases := []struct {
name string
tlsAddr string
host string
target string
want string
}{
{"default port omitted", ":443", "example.com", "/panels?x=1", "https://example.com/panels?x=1"},
{"custom port preserved", ":8443", "host.local:80", "/a", "https://host.local:8443/a"},
{"root path", "0.0.0.0:443", "h", "/", "https://h/"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
h := httpsRedirectHandler(c.tlsAddr)
req := httptest.NewRequest(http.MethodGet, c.target, nil)
req.Host = c.host
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusMovedPermanently {
t.Fatalf("code = %d, want 301", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != c.want {
t.Errorf("Location = %q, want %q", loc, c.want)
}
})
}
}
// -------------------------------------------------------------------------- //
// parseDialogTarget //
// -------------------------------------------------------------------------- //
func TestParseDialogTarget(t *testing.T) {
cases := []struct {
in string
ds, name string
ok bool
}{
{"epics:SR:CURRENT", "epics", "SR:CURRENT", true}, // splits on first ':' only
{"bareName", "srv", "bareName", true}, // defaults to srv
{" spaced ", "srv", "spaced", true},
{"", "", "", false},
{" ", "", "", false},
}
for _, c := range cases {
ds, name, ok := parseDialogTarget(c.in)
if ds != c.ds || name != c.name || ok != c.ok {
t.Errorf("parseDialogTarget(%q) = (%q,%q,%v), want (%q,%q,%v)",
c.in, ds, name, ok, c.ds, c.name, c.ok)
}
}
}
// -------------------------------------------------------------------------- //
// formatAuditValue //
// -------------------------------------------------------------------------- //
func TestFormatAuditValue(t *testing.T) {
cases := []struct {
in any
want string
}{
{"hello", "hello"},
{float64(3.5), "3.5"},
{float64(42), "42"},
{true, "true"},
{false, "false"},
{nil, ""},
{[]any{1.0, 2.0}, "[1,2]"}, // default → JSON
{map[string]any{"a": 1.0}, `{"a":1}`},
}
for _, c := range cases {
if got := formatAuditValue(c.in); got != c.want {
t.Errorf("formatAuditValue(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// -------------------------------------------------------------------------- //
// clientIP //
// -------------------------------------------------------------------------- //
func TestClientIP(t *testing.T) {
t.Run("X-Forwarded-For first hop", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1")
if got := clientIP(req); got != "203.0.113.7" {
t.Errorf("got %q, want 203.0.113.7", got)
}
})
t.Run("X-Real-IP", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Real-IP", "198.51.100.4")
if got := clientIP(req); got != "198.51.100.4" {
t.Errorf("got %q, want 198.51.100.4", got)
}
})
t.Run("RemoteAddr fallback", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.RemoteAddr = "192.0.2.9:54321"
if got := clientIP(req); got != "192.0.2.9" {
t.Errorf("got %q, want 192.0.2.9", got)
}
})
}
+92
View File
@@ -0,0 +1,92 @@
package server
import (
stdlog "log"
"log/slog"
"net/http"
"strings"
"github.com/jcmturner/goidentity/v6"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/jcmturner/gokrb5/v8/service"
"github.com/jcmturner/gokrb5/v8/spnego"
)
// internalUserHeader is the request header the built-in authentication
// middlewares (Kerberos, Basic) use to hand the validated username to the
// downstream access pipeline when no external TrustedUserHeader is configured. It
// is always stripped from inbound requests before validation, so a client cannot
// spoof it.
const internalUserHeader = "X-Uopi-User"
// kerberosAuth wraps next with SPNEGO/Kerberos ("Negotiate") authentication. On a
// successful handshake the authenticated principal's short username (realm
// stripped) is written into userHeader, so the existing access pipeline
// (accessMiddleware / wsHandler, which both read userHeader) resolves identity
// uniformly whether it originated from a trusted proxy header or a Kerberos
// ticket.
//
// challenge controls behaviour when a request carries no valid Negotiate
// credentials:
// - challenge=true (REST/page requests): delegate to gokrb5, which replies
// 401 + WWW-Authenticate: Negotiate so the browser performs SPNEGO.
// - challenge=false (WebSocket upgrades): fall through unauthenticated.
// Browsers cannot attach an Authorization header when opening a WebSocket;
// they only send Negotiate proactively to trusted URIs. When the header is
// present we still validate it, otherwise the session resolves to
// default_user exactly as before.
//
// Any inbound value of userHeader is always discarded before validation: with
// native Kerberos there is no trusted proxy stripping client-supplied headers, so
// only the SPNEGO-validated identity may set it.
func kerberosAuth(kt *keytab.Keytab, spn, userHeader string, challenge bool, log *slog.Logger, next http.Handler) http.Handler {
opts := []func(*service.Settings){
service.Logger(stdlog.New(slogWriter{log: log}, "", 0)),
}
if spn != "" {
opts = append(opts, service.KeytabPrincipal(spn))
}
// inner runs only after a successful SPNEGO handshake: it copies the resolved
// identity into userHeader and continues down the chain.
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := goidentity.FromHTTPRequestContext(r)
if id == nil {
http.Error(w, "kerberos: missing identity", http.StatusUnauthorized)
return
}
user := id.UserName()
if i := strings.IndexByte(user, '@'); i >= 0 {
user = user[:i]
}
r.Header.Set(userHeader, user)
next.ServeHTTP(w, r)
})
validate := spnego.SPNEGOKRB5Authenticate(inner, kt, opts...)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Never trust a client-supplied identity header under native Kerberos.
r.Header.Del(userHeader)
negotiate := strings.HasPrefix(r.Header.Get("Authorization"), spnego.HTTPHeaderAuthResponseValueKey)
if !negotiate && !challenge {
// Best-effort path (WebSocket without proactive credentials): continue
// unauthenticated; downstream resolves the session to default_user.
next.ServeHTTP(w, r)
return
}
// Credentials are present (validate them) or a challenge is required.
validate.ServeHTTP(w, r)
})
}
// slogWriter adapts the std logger gokrb5 expects to slog at debug level so SPNEGO
// validation diagnostics surface without polluting normal output.
type slogWriter struct{ log *slog.Logger }
func (s slogWriter) Write(p []byte) (int, error) {
if s.log != nil {
s.log.Debug("kerberos", "msg", strings.TrimRight(string(p), "\n"))
}
return len(p), nil
}
+102 -7
View File
@@ -4,10 +4,12 @@ import (
"context"
"io/fs"
"log/slog"
"net"
"net/http"
"strings"
"time"
"github.com/jcmturner/gokrb5/v8/keytab"
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/api"
"github.com/uopi/uopi/internal/audit"
@@ -24,17 +26,34 @@ const apiPrefix = "/api/v1"
type Server struct {
httpServer *http.Server
tlsCert string
tlsKey string
tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off
log *slog.Logger
}
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
// synth may be nil if the synthetic data source is not enabled.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
//
// basicAuthFn, when non-nil, enables built-in HTTP Basic authentication validated
// by that function (typically PAM): REST requests are challenged (401 Basic) and
// WebSocket upgrades validate proactively-resent credentials best-effort. It is
// mutually exclusive with Kerberos (krbKeytab). When tlsCert and tlsKey are both
// set the server serves HTTPS via ListenAndServeTLS.
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, krbKeytab *keytab.Keytab, krbSPN string, basicAuthFn func(user, pass string) error, basicAuthRealm, tlsCert, tlsKey, tlsRedirect string, uiDefaultZoom float64, log *slog.Logger) *Server {
if rec == nil {
rec = audit.Nop()
}
mux := http.NewServeMux()
// When built-in authentication (Kerberos or Basic) is enabled but no external
// proxy header is configured, use an internal header to carry the validated
// username downstream.
userHeader := trustedUserHeader
if (krbKeytab != nil || basicAuthFn != nil) && userHeader == "" {
userHeader = internalUserHeader
}
// Health check
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -42,7 +61,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
})
// WebSocket endpoint
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
var wsHandlerH http.Handler = &wsHandler{broker: brk, log: log, userHeader: userHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug}
// Prometheus-format metrics
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
@@ -50,11 +69,37 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
// REST API — registered on a dedicated mux so it can be wrapped with the
// access-control middleware (identity resolution + global level enforcement).
apiMux := http.NewServeMux()
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, uiDefaultZoom, log).Register(apiMux, apiPrefix)
var apiHandler http.Handler = accessMiddleware(policy, userHeader, apiMux)
// Native SPNEGO/Kerberos authentication (optional). REST/page requests are
// challenged (401 Negotiate); WebSocket upgrades validate proactively-sent
// credentials best-effort. Both stash the validated username in userHeader.
var frontendHandler http.Handler = http.FileServerFS(webFS)
if krbKeytab != nil {
apiHandler = kerberosAuth(krbKeytab, krbSPN, userHeader, true, log, apiHandler)
wsHandlerH = kerberosAuth(krbKeytab, krbSPN, userHeader, false, log, wsHandlerH)
} else if basicAuthFn != nil {
// Built-in HTTP Basic authentication (validated by basicAuthFn, e.g. PAM).
// A shared positive-result cache spares a validation round-trip on every
// browser request. REST is challenged; WebSocket upgrades are best-effort.
cache := newCredCache(5 * time.Minute)
apiHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, apiHandler)
wsHandlerH = basicAuth(basicAuthFn, userHeader, basicAuthRealm, false, cache, log, wsHandlerH)
// Challenge the top-level page load too. Browsers only show the native
// Basic login dialog in response to a 401 on a navigation, not on the
// SPA's background fetch('/api/v1/me'); without this the user is never
// prompted and silently resolves to default_user. Once credentials are
// entered the browser caches them for the origin and resends them on
// every asset, API call and the WebSocket upgrade.
frontendHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, frontendHandler)
}
mux.Handle("/ws", wsHandlerH)
mux.Handle(apiPrefix+"/", apiHandler)
// Embedded frontend — must be last (catch-all)
mux.Handle("/", http.FileServerFS(webFS))
mux.Handle("/", frontendHandler)
return &Server{
httpServer: &http.Server{
@@ -64,6 +109,9 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
},
tlsCert: tlsCert,
tlsKey: tlsKey,
tlsRedirect: tlsRedirect,
log: log,
}
}
@@ -108,15 +156,40 @@ func accessMiddleware(policy *access.Policy, userHeader string, next http.Handle
// Start listens and serves until ctx is cancelled.
func (s *Server) Start(ctx context.Context) error {
s.log.Info("listening", "addr", s.httpServer.Addr)
tls := s.tlsCert != "" && s.tlsKey != ""
s.log.Info("listening", "addr", s.httpServer.Addr, "tls", tls)
errCh := make(chan error, 1)
go func() {
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
var err error
if tls {
err = s.httpServer.ListenAndServeTLS(s.tlsCert, s.tlsKey)
} else {
err = s.httpServer.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()
// Optional plain-HTTP redirector: upgrade http:// visitors to the HTTPS
// service instead of letting them hit the TLS port with a cleartext request.
var redirectSrv *http.Server
if tls && s.tlsRedirect != "" {
redirectSrv = &http.Server{
Addr: s.tlsRedirect,
Handler: httpsRedirectHandler(s.httpServer.Addr),
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
s.log.Info("http→https redirect listening", "addr", s.tlsRedirect)
go func() {
if err := redirectSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
}
}()
}
select {
case err := <-errCh:
return err
@@ -124,6 +197,28 @@ func (s *Server) Start(ctx context.Context) error {
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s.log.Info("shutting down")
if redirectSrv != nil {
_ = redirectSrv.Shutdown(shutCtx)
}
return s.httpServer.Shutdown(shutCtx)
}
}
// httpsRedirectHandler 301-redirects any plain-HTTP request to the HTTPS service.
// It preserves the requested hostname and path, swapping in the TLS listener's
// port (omitted when 443).
func httpsRedirectHandler(tlsAddr string) http.Handler {
_, tlsPort, _ := net.SplitHostPort(tlsAddr)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
target := "https://" + host
if tlsPort != "" && tlsPort != "443" {
target += ":" + tlsPort
}
target += r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
})
}
+47
View File
@@ -17,6 +17,7 @@ import (
"github.com/uopi/uopi/internal/access"
"github.com/uopi/uopi/internal/audit"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/controllogic"
"github.com/uopi/uopi/internal/datasource"
"github.com/uopi/uopi/internal/metrics"
)
@@ -37,6 +38,14 @@ type inMsg struct {
// dialogResponse — id of the control-logic dialog being answered.
ID string `json:"id,omitempty"`
// debugSubscribe — live observation / dry-run of a control-logic graph.
Mode string `json:"mode,omitempty"` // "live" | "simulate"
GraphID string `json:"graphId,omitempty"` // live: id of the running graph
Graph json.RawMessage `json:"graph,omitempty"` // simulate: the unsaved graph
// fireTrigger — force a trigger node of the current debug session to run.
NodeID string `json:"nodeId,omitempty"`
// history
Start time.Time `json:"start"`
End time.Time `json:"end"`
@@ -106,6 +115,8 @@ type wsHandler struct {
audit audit.Recorder
// dialogs fans control-logic dialogs to clients and routes responses.
dialogs *DialogHub
// debug fans control-logic node-execution events to watching editors.
debug *DebugHub
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -149,6 +160,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
policy: h.policy,
audit: rec,
dialogs: h.dialogs,
debug: h.debug,
outCh: make(chan []byte, 512),
updateCh: make(chan broker.Update, 1024),
subs: make(map[broker.SignalRef]func()),
@@ -160,6 +172,10 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.dialogs.add(c)
defer h.dialogs.remove(c)
}
// Tear down any debug subscription (and its simulate sandbox) on disconnect.
if h.debug != nil {
defer h.debug.unsubscribe(c)
}
var wg sync.WaitGroup
wg.Add(3)
@@ -189,6 +205,7 @@ type wsClient struct {
policy *access.Policy // global access-level enforcement
audit audit.Recorder // signal-write audit recorder (never nil)
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
debug *DebugHub // control-logic debug fan-out (nil if disabled)
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
@@ -287,6 +304,16 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
c.handleHistory(ctx, msg)
case "dialogResponse":
c.handleDialogResponse(ctx, msg)
case "debugSubscribe":
c.handleDebugSubscribe(ctx, msg)
case "debugUnsubscribe":
if c.debug != nil {
c.debug.unsubscribe(c)
}
case "fireTrigger":
if c.debug != nil {
c.debug.fire(c, msg.NodeID)
}
default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
}
@@ -423,6 +450,26 @@ func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
c.dialogs.respond(ctx, c, msg.ID, num)
}
// handleDebugSubscribe starts a control-logic debug session for this client.
// mode "live" observes the running graph identified by GraphID; mode "simulate"
// dry-runs the unsaved Graph payload in a sandbox (no real writes). Either way
// the previous session (if any) is replaced; node events arrive as "debugNode".
func (c *wsClient) handleDebugSubscribe(ctx context.Context, msg inMsg) {
if c.debug == nil {
return
}
if msg.Mode == "simulate" {
var g controllogic.Graph
if err := json.Unmarshal(msg.Graph, &g); err != nil {
c.sendError(ctx, "DEBUG_ERROR", "invalid graph: "+err.Error())
return
}
c.debug.subscribeSimulate(c, g)
return
}
c.debug.subscribeLive(c, msg.GraphID)
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
metrics.IncHistoryReqs()
ds, ok := c.broker.Source(msg.DS)
+4 -1
View File
@@ -22,6 +22,8 @@ type InterfaceMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Version int `json:"version"`
// Kind is "plot" for split-layout plot panels, empty for free-form panels.
Kind string `json:"kind,omitempty"`
}
// VersionMeta describes a single persisted revision of an interface.
@@ -134,6 +136,7 @@ type rootAttrs struct {
Name string `xml:"name,attr"`
Version int `xml:"version,attr"`
Tag string `xml:"tag,attr"`
Kind string `xml:"kind,attr"`
}
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
@@ -145,7 +148,7 @@ func (s *Store) readMeta(id string) (InterfaceMeta, error) {
if err := xml.Unmarshal(data, &root); err != nil {
return InterfaceMeta{}, err
}
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version, Kind: root.Kind}, nil
}
// Get returns the raw XML bytes for the interface with the given ID.
+374
View File
@@ -0,0 +1,374 @@
package storage_test
import (
"errors"
"strings"
"testing"
"github.com/uopi/uopi/internal/storage"
)
// mkVersioned creates an interface and pushes it to version 3 by updating
// twice, returning the id. After this the store holds: current (v3) plus
// backups id.v1.xml and id.v2.xml.
func mkVersioned(t *testing.T) (*storage.Store, string) {
t.Helper()
s := newStore(t)
id, err := s.Create([]byte(`<interface id="vers" name="V1" version="1"><widget/></interface>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := s.Update(id, []byte(`<interface id="vers" name="V2" version="1"><widget/></interface>`), "second"); err != nil {
t.Fatalf("Update→v2: %v", err)
}
if err := s.Update(id, []byte(`<interface id="vers" name="V3" version="1"><widget/></interface>`), ""); err != nil {
t.Fatalf("Update→v3: %v", err)
}
return s, id
}
// -------------------------------------------------------------------------- //
// Groups //
// -------------------------------------------------------------------------- //
func TestReadGroupsDefaultsEmptyArray(t *testing.T) {
s := newStore(t)
data, err := s.ReadGroups()
if err != nil {
t.Fatalf("ReadGroups: %v", err)
}
if string(data) != "[]" {
t.Errorf("ReadGroups default = %q, want %q", data, "[]")
}
}
func TestWriteThenReadGroups(t *testing.T) {
s := newStore(t)
want := `[{"id":"a","name":"Area A"}]`
if err := s.WriteGroups([]byte(want)); err != nil {
t.Fatalf("WriteGroups: %v", err)
}
got, err := s.ReadGroups()
if err != nil {
t.Fatalf("ReadGroups: %v", err)
}
if string(got) != want {
t.Errorf("ReadGroups = %q, want %q", got, want)
}
}
// -------------------------------------------------------------------------- //
// Versions //
// -------------------------------------------------------------------------- //
func TestVersionsNewestFirstWithCurrentFlag(t *testing.T) {
s, id := mkVersioned(t)
vs, err := s.Versions(id)
if err != nil {
t.Fatalf("Versions: %v", err)
}
if len(vs) != 3 {
t.Fatalf("expected 3 versions, got %d", len(vs))
}
if vs[0].Version != 3 || vs[1].Version != 2 || vs[2].Version != 1 {
t.Errorf("not newest-first: %d, %d, %d", vs[0].Version, vs[1].Version, vs[2].Version)
}
if !vs[0].Current {
t.Error("v3 should be flagged Current")
}
if vs[1].Current || vs[2].Current {
t.Error("backup revisions must not be flagged Current")
}
// The "second" tag was stamped on the revision that became backup v2.
if vs[1].Tag != "second" {
t.Errorf("v2 tag = %q, want %q", vs[1].Tag, "second")
}
}
func TestVersionsNotFound(t *testing.T) {
s := newStore(t)
if _, err := s.Versions("ghost"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
}
}
func TestVersionsInvalidID(t *testing.T) {
s := newStore(t)
if _, err := s.Versions("../x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions bad ID: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// GetVersion //
// -------------------------------------------------------------------------- //
func TestGetVersionCurrentAndBackup(t *testing.T) {
s, id := mkVersioned(t)
cur, err := s.GetVersion(id, 3)
if err != nil {
t.Fatalf("GetVersion(3): %v", err)
}
if !strings.Contains(string(cur), `name="V3"`) {
t.Errorf("GetVersion(3) wrong content: %s", cur)
}
old, err := s.GetVersion(id, 1)
if err != nil {
t.Fatalf("GetVersion(1): %v", err)
}
if !strings.Contains(string(old), `name="V1"`) {
t.Errorf("GetVersion(1) wrong content: %s", old)
}
}
func TestGetVersionMissingRevision(t *testing.T) {
s, id := mkVersioned(t)
if _, err := s.GetVersion(id, 99); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("GetVersion(99): want ErrNotFound, got %v", err)
}
}
func TestGetVersionMissingInterface(t *testing.T) {
s := newStore(t)
if _, err := s.GetVersion("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("GetVersion missing iface: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// SetVersionTag //
// -------------------------------------------------------------------------- //
func TestSetVersionTagOnBackupAndClear(t *testing.T) {
s, id := mkVersioned(t)
// Tag a backup revision in place (no new revision created).
if err := s.SetVersionTag(id, 1, "milestone"); err != nil {
t.Fatalf("SetVersionTag(1): %v", err)
}
vs, _ := s.Versions(id)
if len(vs) != 3 {
t.Fatalf("tagging must not add a revision: got %d", len(vs))
}
var v1 *storage.VersionMeta
for i := range vs {
if vs[i].Version == 1 {
v1 = &vs[i]
}
}
if v1 == nil || v1.Tag != "milestone" {
t.Fatalf("v1 tag not set: %+v", v1)
}
// Clearing the tag.
if err := s.SetVersionTag(id, 1, ""); err != nil {
t.Fatalf("SetVersionTag clear: %v", err)
}
vs, _ = s.Versions(id)
for _, v := range vs {
if v.Version == 1 && v.Tag != "" {
t.Errorf("v1 tag should be cleared, got %q", v.Tag)
}
}
}
func TestSetVersionTagCurrent(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 3, "live"); err != nil {
t.Fatalf("SetVersionTag(3): %v", err)
}
vs, _ := s.Versions(id)
if vs[0].Version != 3 || vs[0].Tag != "live" {
t.Errorf("current tag not applied: %+v", vs[0])
}
}
func TestSetVersionTagMissing(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 99, "x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("SetVersionTag missing rev: want ErrNotFound, got %v", err)
}
if err := s.SetVersionTag("../bad", 1, "x"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("SetVersionTag bad ID: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Promote //
// -------------------------------------------------------------------------- //
func TestPromoteIsNonDestructive(t *testing.T) {
s, id := mkVersioned(t)
// Promote v1 — its content becomes the new current revision (v4), while
// every prior revision is preserved.
if err := s.Promote(id, 1); err != nil {
t.Fatalf("Promote(1): %v", err)
}
vs, err := s.Versions(id)
if err != nil {
t.Fatalf("Versions after promote: %v", err)
}
if len(vs) != 4 {
t.Fatalf("expected 4 versions after promote, got %d", len(vs))
}
if vs[0].Version != 4 || !vs[0].Current {
t.Errorf("new current should be v4: %+v", vs[0])
}
if vs[0].Name != "V1" {
t.Errorf("promoted content should be V1's, got name %q", vs[0].Name)
}
if vs[0].Tag != "restored from v1" {
t.Errorf("promote tag = %q", vs[0].Tag)
}
}
func TestPromoteMissing(t *testing.T) {
s, id := mkVersioned(t)
if err := s.Promote(id, 99); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Promote missing rev: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Fork //
// -------------------------------------------------------------------------- //
func TestForkResetsVersionAndClearsTag(t *testing.T) {
s, id := mkVersioned(t)
if err := s.SetVersionTag(id, 2, "tagged"); err != nil {
t.Fatalf("SetVersionTag: %v", err)
}
newID, err := s.Fork(id, 2)
if err != nil {
t.Fatalf("Fork(2): %v", err)
}
if newID == id {
t.Fatal("Fork must produce a distinct ID")
}
data, err := s.Get(newID)
if err != nil {
t.Fatalf("Get(fork): %v", err)
}
str := string(data)
if !strings.Contains(str, `name="V2"`) {
t.Errorf("fork should carry V2 content: %s", str)
}
if !strings.Contains(str, `version="1"`) {
t.Errorf("fork version should reset to 1: %s", str)
}
if !strings.Contains(str, `id="`+newID+`"`) {
t.Errorf("fork id attr should be stamped: %s", str)
}
if strings.Contains(str, "tagged") {
t.Errorf("fork should clear the tag: %s", str)
}
// The fork is an independent, listable interface at v1.
vs, err := s.Versions(newID)
if err != nil {
t.Fatalf("Versions(fork): %v", err)
}
if len(vs) != 1 || vs[0].Version != 1 {
t.Errorf("fork should have a single v1 revision, got %+v", vs)
}
}
func TestForkMissing(t *testing.T) {
s := newStore(t)
if _, err := s.Fork("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
}
}
// -------------------------------------------------------------------------- //
// Create: slug derivation + attribute stamping //
// -------------------------------------------------------------------------- //
func TestCreateSlugifiesNameWhenNoID(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(`<interface name="My Cool Panel!" version="1"/>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if id != "my-cool-panel" {
t.Errorf("slug id = %q, want %q", id, "my-cool-panel")
}
}
func TestCreateEmptyNameFallsBackToInterface(t *testing.T) {
s := newStore(t)
id, err := s.Create([]byte(`<interface name="" version="1"/>`), "")
if err != nil {
t.Fatalf("Create: %v", err)
}
if id != "interface" {
t.Errorf("fallback id = %q, want %q", id, "interface")
}
}
func TestCreateStampsMissingVersionAndID(t *testing.T) {
s := newStore(t)
// XML carries neither id nor version attributes — Create must insert both.
id, err := s.Create([]byte(`<interface name="Stamp Me"><widget/></interface>`), "label")
if err != nil {
t.Fatalf("Create: %v", err)
}
data, err := s.Get(id)
if err != nil {
t.Fatalf("Get: %v", err)
}
str := string(data)
if !strings.Contains(str, `version="1"`) {
t.Errorf("Create should stamp version=1: %s", str)
}
if !strings.Contains(str, `id="`+id+`"`) {
t.Errorf("Create should stamp id: %s", str)
}
if !strings.Contains(str, `tag="label"`) {
t.Errorf("Create should stamp tag: %s", str)
}
}
// -------------------------------------------------------------------------- //
// Update tagging + backup chain //
// -------------------------------------------------------------------------- //
func TestDeleteMovesVersionedBackups(t *testing.T) {
s, id := mkVersioned(t) // current + id.v1.xml + id.v2.xml
if err := s.Delete(id); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get(id); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
}
if _, err := s.Versions(id); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Versions after delete: want ErrNotFound, got %v", err)
}
}
func TestListSkipsVersionedBackups(t *testing.T) {
s, _ := mkVersioned(t) // leaves id.v1.xml and id.v2.xml on disk
list, err := s.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 {
t.Fatalf("List should skip versioned backups, got %d entries: %+v", len(list), list)
}
}
func TestUpdateStampsIncrementingVersion(t *testing.T) {
s, id := mkVersioned(t)
data, err := s.Get(id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if !strings.Contains(string(data), `version="3"`) {
t.Errorf("current should be version 3 after two updates: %s", data)
}
}
+1 -1
View File
@@ -7,8 +7,8 @@ import (
"time"
"github.com/uopi/goca"
"github.com/uopi/goca/testca"
"github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
)
// newTestClient creates a Client pointing only at the fake server's addresses.
+109
View File
@@ -0,0 +1,109 @@
package ca
import (
"context"
"net"
"testing"
"github.com/uopi/goca/proto"
)
// ---- encodePut: every DBF branch + error paths -----------------------
func TestEncodePut(t *testing.T) {
cases := []struct {
name string
dbf int
v any
wantDBR uint16
}{
{"double", proto.DBFDouble, 1.5, proto.DBRDouble},
{"float", proto.DBFFloat, float32(2.0), proto.DBRDouble},
{"long", proto.DBFLong, int(3), proto.DBRLong},
{"short", proto.DBFShort, int16(4), proto.DBRShort},
{"char", proto.DBFChar, int(5), proto.DBRShort},
{"enum", proto.DBFEnum, int(1), proto.DBRShort},
{"string", proto.DBFString, "hi", proto.DBRString},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
dbr, payload, err := encodePut(c.dbf, c.v)
if err != nil {
t.Fatalf("encodePut: %v", err)
}
if dbr != c.wantDBR {
t.Errorf("dbr = %d, want %d", dbr, c.wantDBR)
}
if len(payload) == 0 {
t.Error("empty payload")
}
})
}
// Coercion failures propagate.
if _, _, err := encodePut(proto.DBFDouble, "nope"); err == nil {
t.Error("double from string: want error")
}
if _, _, err := encodePut(proto.DBFLong, "nope"); err == nil {
t.Error("long from string: want error")
}
if _, _, err := encodePut(proto.DBFShort, "nope"); err == nil {
t.Error("short from string: want error")
}
// Unsupported field type.
if _, _, err := encodePut(9999, 1); err == nil {
t.Error("unsupported DBF: want error")
}
}
// ---- NewClient: error path + default name/host -----------------------
func TestNewClientNoAddrs(t *testing.T) {
if _, err := NewClient(context.Background(), Config{AutoAddrList: false}); err == nil {
t.Error("NewClient with no addresses: want error")
}
}
func TestNewClientDefaultsNameHost(t *testing.T) {
// AddrList present but ClientName/HostName empty → default branches run.
cli, err := NewClient(context.Background(), Config{
AddrList: []string{"127.0.0.1:5064"},
AutoAddrList: false,
})
if err != nil {
t.Fatalf("NewClient: %v", err)
}
defer cli.Close()
if cli.cfg.ClientName == "" {
t.Error("ClientName should be defaulted")
}
}
// ---- EncodeSearchReply with an explicit server IP --------------------
func TestEncodeSearchReplyExplicitIP(t *testing.T) {
pkt := EncodeSearchReply(7, net.ParseIP("10.1.2.3"), 5064)
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
if err != nil {
t.Fatalf("DecodeHeader: %v", err)
}
if h.Command != proto.CmdSearch {
t.Errorf("command = %d, want CmdSearch", h.Command)
}
}
// ---- parseReply robustness -------------------------------------------
func TestParseReplyTruncated(t *testing.T) {
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
// Must not panic on a too-short datagram.
se.parseReply([]byte{0x00, 0x01}, src)
}
func TestParseReplyUnknownSearchID(t *testing.T) {
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
// Valid reply but no waiter is registered for this search ID → dropped.
se.parseReply(EncodeSearchReply(12345, nil, 5064), src)
}
+47
View File
@@ -0,0 +1,47 @@
package ca_test
import (
"context"
"testing"
"time"
)
// TestGetCtrlNotFound covers the resolve-failure branch of GetCtrl.
func TestGetCtrlNotFound(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if _, err := cli.GetCtrl(ctx, "NO:SUCH:PV"); err == nil {
t.Fatal("GetCtrl on missing PV: want error")
}
}
// TestPutNotFound covers the resolve-failure branch of Put.
func TestPutNotFound(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
if err := cli.Put(ctx, "NO:SUCH:PV", 1.0); err == nil {
t.Fatal("Put on missing PV: want error")
}
}
// TestPutUncoercibleValue covers the encodePut-failure branch of Put: the PV
// resolves but the supplied value cannot be coerced to the native type.
func TestPutUncoercibleValue(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := cli.Put(ctx, "TEST:DOUBLE", []int{1, 2, 3}); err == nil {
t.Fatal("Put with uncoercible value: want error")
}
}
+104
View File
@@ -0,0 +1,104 @@
package ca_test
import (
"context"
"math"
"testing"
"time"
"github.com/uopi/goca/proto"
"github.com/uopi/goca/testca"
)
// TestGetDisconnectMidRequest covers the "circuit disconnected during GET" path
// in conn.go: the server drops the connection while a READ_NOTIFY is in flight,
// so the in-flight reply channel is closed by the reconnect loop and the waiter
// unblocks with ok=false.
func TestGetDisconnectMidRequest(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// First GET establishes the channel/circuit successfully.
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
t.Fatalf("initial Get: %v", err)
}
// Arm a mid-request disconnect for the next READ_NOTIFY.
srv.SetGetFault(testca.GetFaultDisconnect)
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
t.Fatal("Get with mid-request disconnect: want error")
}
}
// TestGetCorruptReply covers the "failed to decode GET reply" path: the server
// answers READ_NOTIFY with a payload too short for DecodeTimeValue.
func TestGetCorruptReply(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Establish the channel first so resolution succeeds.
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
t.Fatalf("initial Get: %v", err)
}
srv.SetGetFault(testca.GetFaultCorrupt)
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
t.Fatal("Get with corrupt reply: want error")
}
}
// TestServerDisconnectReconnects covers the CmdServerDisc dispatch branch and the
// reconnect loop: after an orderly server disconnect the client transparently
// reconnects, re-creates its channel, re-subscribes the monitor, and continues to
// receive updates.
func TestServerDisconnectReconnects(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ch := make(chan proto.TimeValue, 16)
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
defer unsub()
// Drain the initial value.
select {
case <-ch:
case <-ctx.Done():
t.Fatal("timeout waiting for initial value")
}
// Force an orderly disconnect; the client must reconnect on its own.
srv.Disconnect()
// After the reconnect settles, push a fresh value and expect to see it.
// The reconnect back-off is ~1s, so poll generously and re-arm the value
// until it is observed (the re-subscribe also delivers an initial value).
deadline := time.After(8 * time.Second)
tick := time.NewTicker(300 * time.Millisecond)
defer tick.Stop()
for {
select {
case tv := <-ch:
if math.Abs(tv.Double-88.8) < 1e-6 {
return // reconnect succeeded and the update flowed through
}
case <-tick.C:
_ = srv.SetValue("TEST:DOUBLE", 88.8)
case <-deadline:
t.Fatal("timeout waiting for post-reconnect update")
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package ca_test
import (
"context"
"testing"
"time"
)
// TestGetCtrl exercises GetCtrl across the native DBF types served by the mock,
// covering NativeCtrlType dispatch and the DBR_CTRL_* decoders.
func TestGetCtrl(t *testing.T) {
srv := newTestServer(t)
cli := newTestClient(t, srv)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
t.Run("double", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:DOUBLE")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Double == nil {
t.Fatal("expected Double ctrl info")
}
})
t.Run("long", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:LONG")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Long == nil {
t.Fatal("expected Long ctrl info")
}
})
t.Run("enum", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:ENUM")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Enum == nil {
t.Fatal("expected Enum ctrl info")
}
})
t.Run("string", func(t *testing.T) {
ci, err := cli.GetCtrl(ctx, "TEST:STRING")
if err != nil {
t.Fatalf("GetCtrl: %v", err)
}
if ci.Str == nil {
t.Fatal("expected Str ctrl info")
}
})
}
+139
View File
@@ -0,0 +1,139 @@
package ca
import (
"testing"
"github.com/uopi/goca/proto"
)
// ---- value coercion helpers ------------------------------------------
func TestToFloat64(t *testing.T) {
cases := []struct {
in any
want float64
}{
{float64(1.5), 1.5},
{float32(2.5), 2.5},
{int(3), 3},
{int64(4), 4},
{int32(5), 5},
{int16(6), 6},
{uint64(7), 7},
{uint32(8), 8},
{true, 1},
{false, 0},
}
for _, c := range cases {
got, err := toFloat64(c.in)
if err != nil || got != c.want {
t.Errorf("toFloat64(%v) = %v, %v; want %v", c.in, got, err, c.want)
}
}
if _, err := toFloat64("nope"); err == nil {
t.Error("toFloat64(string): want error")
}
}
func TestToInt32(t *testing.T) {
cases := []struct {
in any
want int32
}{
{int(3), 3},
{int64(4), 4},
{int32(5), 5},
{int16(6), 6},
{float64(7.9), 7},
{float32(8.9), 8},
{true, 1},
{false, 0},
}
for _, c := range cases {
got, err := toInt32(c.in)
if err != nil || got != c.want {
t.Errorf("toInt32(%v) = %v, %v; want %v", c.in, got, err, c.want)
}
}
if _, err := toInt32("nope"); err == nil {
t.Error("toInt32(string): want error")
}
}
func TestToString(t *testing.T) {
if s, _ := toString("hi"); s != "hi" {
t.Errorf("toString(string) = %q", s)
}
if s, _ := toString([]byte("bytes")); s != "bytes" {
t.Errorf("toString([]byte) = %q", s)
}
if s, _ := toString(42); s != "42" {
t.Errorf("toString(int) = %q, want 42", s)
}
}
// ---- ConfigFromEnv ---------------------------------------------------
func TestConfigFromEnv(t *testing.T) {
t.Setenv("EPICS_CA_ADDR_LIST", "1.2.3.4 5.6.7.8")
t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "no")
cfg := ConfigFromEnv()
if len(cfg.AddrList) != 2 || cfg.AddrList[0] != "1.2.3.4" {
t.Errorf("AddrList = %v", cfg.AddrList)
}
if cfg.AutoAddrList {
t.Error("AutoAddrList should be false when env = no")
}
if cfg.ClientName == "" {
t.Error("ClientName should be populated")
}
t.Setenv("EPICS_CA_ADDR_LIST", "")
t.Setenv("EPICS_CA_AUTO_ADDR_LIST", "yes")
cfg = ConfigFromEnv()
if !cfg.AutoAddrList {
t.Error("AutoAddrList should default to true")
}
if len(cfg.AddrList) != 0 {
t.Errorf("AddrList should be empty, got %v", cfg.AddrList)
}
}
// ---- address helpers -------------------------------------------------
func TestResolveAddrs(t *testing.T) {
got := resolveAddrs([]string{"host", "host2:9999", ""}, 5064)
if len(got) != 2 || got[0] != "host:5064" || got[1] != "host2:9999" {
t.Errorf("resolveAddrs = %v", got)
}
}
func TestLocalBroadcastAddrs(t *testing.T) {
// Should not panic; the result is environment-dependent.
_ = localBroadcastAddrs(5064)
}
// ---- isAllFF ---------------------------------------------------------
func TestIsAllFF(t *testing.T) {
if !isAllFF([]byte{0xFF, 0xFF, 0xFF}) {
t.Error("all-FF should be true")
}
if isAllFF([]byte{0xFF, 0x00}) {
t.Error("mixed should be false")
}
}
// ---- EncodedSearchPair -----------------------------------------------
func TestEncodedSearchPair(t *testing.T) {
pkt := EncodedSearchPair("TEST:PV", 7)
// First header must be a VERSION message.
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
if err != nil {
t.Fatalf("DecodeHeader: %v", err)
}
if h.Command != proto.CmdVersion {
t.Errorf("first command = %d, want CmdVersion", h.Command)
}
}
+163
View File
@@ -0,0 +1,163 @@
package proto_test
import (
"bytes"
"encoding/binary"
"math"
"testing"
"github.com/uopi/goca/proto"
)
// -------------------------------------------------------------------------- //
// DBR_TIME_* variants not covered by proto_test.go //
// -------------------------------------------------------------------------- //
func TestDecodeTimeFloat(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := make([]byte, 4)
binary.BigEndian.PutUint32(val, math.Float32bits(2.5))
tv, ok := proto.DecodeTimeValue(proto.DBRTimeFloat, 1, append(hdr, val...))
if !ok || math.Abs(float64(tv.Float)-2.5) > 1e-6 {
t.Errorf("float: ok=%v Float=%g", ok, tv.Float)
}
}
func TestDecodeTimeShort(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := []byte{0x00, 0x00, 0xFF, 0xD6} // 2-byte pad + int16(-42)
tv, ok := proto.DecodeTimeValue(proto.DBRTimeShort, 1, append(hdr, val...))
if !ok || tv.Short != -42 {
t.Errorf("short: ok=%v Short=%d", ok, tv.Short)
}
}
func TestDecodeTimeChar(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
val := []byte{0x00, 0x00, 0x00, 0x41} // pad0 + pad1 + value 'A'
tv, ok := proto.DecodeTimeValue(proto.DBRTimeChar, 1, append(hdr, val...))
if !ok || tv.Char != 0x41 {
t.Errorf("char: ok=%v Char=%d", ok, tv.Char)
}
}
func TestDecodeTimeUnknownType(t *testing.T) {
hdr := buildTimeHeader(0, 0, 0, 0)
if _, ok := proto.DecodeTimeValue(9999, 1, append(hdr, make([]byte, 16)...)); ok {
t.Error("unknown DBR type should return false")
}
}
// -------------------------------------------------------------------------- //
// DBR_CTRL_LONG / DBR_CTRL_STRING //
// -------------------------------------------------------------------------- //
func TestDecodeCtrlLong(t *testing.T) {
p := make([]byte, 48)
binary.BigEndian.PutUint16(p[2:], 2) // severity = Major
copy(p[4:], "counts")
i32 := func(off int, v int32) { binary.BigEndian.PutUint32(p[off:], uint32(v)) }
i32(12, 1000) // upper_disp
i32(16, -10) // lower_disp
i32(44, 777) // value
cl, ok := proto.DecodeCtrlLong(p)
if !ok {
t.Fatal("DecodeCtrlLong returned false")
}
if cl.Units != "counts" || cl.Value != 777 || cl.UpperDispLimit != 1000 || cl.LowerDispLimit != -10 {
t.Errorf("got %+v", cl)
}
if cl.Severity != proto.SeverityMajor {
t.Errorf("severity = %v, want Major", cl.Severity)
}
if _, ok := proto.DecodeCtrlLong(make([]byte, 10)); ok {
t.Error("short CtrlLong payload should return false")
}
}
func TestDecodeCtrlString(t *testing.T) {
p := make([]byte, 44)
binary.BigEndian.PutUint16(p[2:], 1) // severity = Minor
copy(p[4:], "READY")
cs, ok := proto.DecodeCtrlString(p)
if !ok {
t.Fatal("DecodeCtrlString returned false")
}
if cs.Value != "READY" || cs.Severity != proto.SeverityMinor {
t.Errorf("got %+v", cs)
}
if _, ok := proto.DecodeCtrlString(make([]byte, 10)); ok {
t.Error("short CtrlString payload should return false")
}
}
// -------------------------------------------------------------------------- //
// EncodeShort //
// -------------------------------------------------------------------------- //
func TestEncodeShort(t *testing.T) {
b := proto.EncodeShort(-7)
if len(b) != 8 {
t.Fatalf("len = %d, want 8", len(b))
}
if got := int16(binary.BigEndian.Uint16(b)); got != -7 {
t.Errorf("decoded = %d, want -7", got)
}
}
// -------------------------------------------------------------------------- //
// NativeCtrlType //
// -------------------------------------------------------------------------- //
func TestNativeCtrlType(t *testing.T) {
cases := []struct {
dbf int
want uint16
}{
{proto.DBFDouble, proto.DBRCtrlDouble},
{proto.DBFFloat, proto.DBRCtrlDouble},
{proto.DBFLong, proto.DBRCtrlLong},
{proto.DBFShort, proto.DBRCtrlLong},
{proto.DBFChar, proto.DBRCtrlLong},
{proto.DBFEnum, proto.DBRCtrlEnum},
{proto.DBFString, proto.DBRCtrlString},
{9999, proto.DBRCtrlDouble}, // default
}
for _, c := range cases {
if got := proto.NativeCtrlType(c.dbf); got != c.want {
t.Errorf("NativeCtrlType(%d) = %d, want %d", c.dbf, got, c.want)
}
}
}
// -------------------------------------------------------------------------- //
// DecodeHeader: extended encoding + error path //
// -------------------------------------------------------------------------- //
func TestDecodeHeaderExtended(t *testing.T) {
raw := make([]byte, 24)
binary.BigEndian.PutUint16(raw[0:], proto.CmdReadNotify)
binary.BigEndian.PutUint16(raw[2:], 0xFFFF) // extended marker
binary.BigEndian.PutUint16(raw[6:], 0x0000)
binary.BigEndian.PutUint32(raw[16:], 70000) // real payload size
binary.BigEndian.PutUint32(raw[20:], 5) // real count
h, n, err := proto.DecodeHeader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
if n != 24 {
t.Errorf("bytes read = %d, want 24", n)
}
if h.PayloadSize != 70000 || h.DataCount != 5 {
t.Errorf("extended header = %+v", h)
}
}
func TestDecodeHeaderTruncated(t *testing.T) {
if _, _, err := proto.DecodeHeader(bytes.NewReader([]byte{0x00, 0x01})); err == nil {
t.Error("truncated header: want error")
}
}
+60 -1
View File
@@ -32,10 +32,18 @@ type Server struct {
mu sync.RWMutex
pvs map[string]*serverPV
subs map[uint32]*serverSub // subID → sub
getFault int // fault to inject on the next READ_NOTIFY
done chan struct{}
}
// Fault modes for SetGetFault, used to exercise the client's error paths.
const (
GetFaultNone = iota // serve replies normally
GetFaultDisconnect // drop the connection mid-request
GetFaultCorrupt // reply with an undecodable (too-short) payload
)
type serverPV struct {
spec PVSpec
mu sync.RWMutex
@@ -93,6 +101,29 @@ func (s *Server) Close() {
s.udpCn.Close()
}
// SetGetFault arms a fault to be injected on the next READ_NOTIFY request.
// It is reset to GetFaultNone after a single request is faulted.
func (s *Server) SetGetFault(mode int) {
s.mu.Lock()
s.getFault = mode
s.mu.Unlock()
}
// Disconnect pushes a SERVER_DISCONN message to every connected subscriber,
// simulating an orderly server shutdown that forces clients to reconnect.
func (s *Server) Disconnect() {
msg := proto.BuildMessage(proto.Header{Command: proto.CmdServerDisc}, nil)
seen := make(map[net.Conn]bool)
s.mu.RLock()
for _, sub := range s.subs {
if sub.conn != nil && !seen[sub.conn] {
seen[sub.conn] = true
_, _ = sub.conn.Write(msg)
}
}
s.mu.RUnlock()
}
// SetValue updates a PV's value and pushes a monitor event to all subscribers.
func (s *Server) SetValue(pvName string, val any) error {
s.mu.RLock()
@@ -350,6 +381,31 @@ func (s *Server) handleConn(conn net.Conn) {
continue
}
// Fault injection: consume a one-shot armed fault, if any.
s.mu.Lock()
fault := s.getFault
s.getFault = GetFaultNone
s.mu.Unlock()
switch fault {
case GetFaultDisconnect:
// Drop the connection mid-request: the client's in-flight GET
// unblocks with ok=false ("circuit disconnected during GET").
conn.Close()
return
case GetFaultCorrupt:
// Reply with a too-short payload so DecodeTimeValue fails
// ("failed to decode GET reply").
reply := proto.BuildMessage(proto.Header{
Command: proto.CmdReadNotify,
DataType: hdr.DataType,
DataCount: hdr.DataCount,
Parameter1: cid,
Parameter2: ioid,
}, []byte{0, 0, 0, 0})
conn.Write(reply)
continue
}
s.mu.RLock()
pv := s.pvs[pvName]
s.mu.RUnlock()
@@ -591,7 +647,10 @@ func nullStr(b []byte) string {
// Minimal io.Reader for proto.DecodeHeader //
// -------------------------------------------------------------------------- //
type bytesReader struct{ b []byte; i int }
type bytesReader struct {
b []byte
i int
}
func newBytesReader(b []byte) *bytesReader { return &bytesReader{b: b} }
-1
View File
@@ -573,4 +573,3 @@ func asError(s Status) error {
}
return fmt.Errorf("%s", s.Error())
}
+175
View File
@@ -0,0 +1,175 @@
package pva
import (
"bytes"
"encoding/binary"
"testing"
)
// ---- Header codec -----------------------------------------------------
func TestHeaderRoundTrip(t *testing.T) {
h := PVAHeader{Version: 1, Flags: flagApp, Command: CmdGet, Size: 1234}
var buf bytes.Buffer
if err := WriteHeader(&buf, h); err != nil {
t.Fatal(err)
}
got, err := ReadHeader(&buf)
if err != nil {
t.Fatal(err)
}
if got.Version != h.Version || got.Command != h.Command || got.Size != h.Size {
t.Errorf("round-trip header: got %+v want %+v", got, h)
}
}
func TestReadHeaderBadMagic(t *testing.T) {
raw := []byte{0xAB, 0x01, 0x00, CmdGet, 0, 0, 0, 0}
if _, err := ReadHeader(bytes.NewReader(raw)); err == nil {
t.Error("bad magic: want error")
}
}
func TestReadHeaderTruncated(t *testing.T) {
if _, err := ReadHeader(bytes.NewReader([]byte{0xCA, 0x01})); err == nil {
t.Error("truncated header: want error")
}
}
func TestHeaderBigEndian(t *testing.T) {
// Hand-assemble a big-endian server header with size 0x00010203.
raw := []byte{magic, 0x01, flagBigEndian | flagFromServer, CmdMonitor, 0x00, 0x01, 0x02, 0x03}
h, err := ReadHeader(bytes.NewReader(raw))
if err != nil {
t.Fatal(err)
}
if h.isLittleEndian() {
t.Error("expected big-endian header")
}
if !h.isFromServer() {
t.Error("expected from-server bit")
}
if h.byteOrder() != binary.BigEndian {
t.Error("byteOrder should be big-endian")
}
if h.Size != 0x00010203 {
t.Errorf("size = %#x, want 0x00010203", h.Size)
}
}
func TestHeaderPredicates(t *testing.T) {
le := PVAHeader{Flags: flagApp}
if !le.isLittleEndian() || le.isFromServer() || le.isControl() {
t.Errorf("app/LE header predicates wrong: %+v", le)
}
ctrl := PVAHeader{Flags: flagControl}
if !ctrl.isControl() {
t.Error("control header should report isControl")
}
}
// ---- BuildMessage -----------------------------------------------------
func TestBuildMessage(t *testing.T) {
payload := []byte{1, 2, 3, 4, 5}
// Pass the server flag too; BuildMessage must clear it (client direction).
msg := BuildMessage(CmdCreateChannel, flagFromServer, payload)
h, err := ReadHeader(bytes.NewReader(msg))
if err != nil {
t.Fatal(err)
}
if h.Command != CmdCreateChannel {
t.Errorf("command = %#x, want %#x", h.Command, CmdCreateChannel)
}
if h.isFromServer() {
t.Error("BuildMessage must clear the from-server flag")
}
if int(h.Size) != len(payload) {
t.Errorf("size = %d, want %d", h.Size, len(payload))
}
if got := msg[headerSize:]; !bytes.Equal(got, payload) {
t.Errorf("payload = %v, want %v", got, payload)
}
}
// ---- Status decoding --------------------------------------------------
func TestReadStatusOKShort(t *testing.T) {
s, err := ReadStatus(bytes.NewReader([]byte{StatusOK}), binary.LittleEndian)
if err != nil {
t.Fatal(err)
}
if !s.OK() || s.Error() != "" {
t.Errorf("short OK status: got %+v", s)
}
}
func TestReadStatusError(t *testing.T) {
var buf bytes.Buffer
buf.WriteByte(StatusError)
buf.WriteByte(0x04) // compact size 4
buf.WriteString("boom")
buf.WriteByte(0x00) // empty stack
s, err := ReadStatus(&buf, binary.LittleEndian)
if err != nil {
t.Fatal(err)
}
if s.OK() {
t.Error("error status should not be OK")
}
if s.Message != "boom" {
t.Errorf("message = %q, want boom", s.Message)
}
if s.Error() == "" {
t.Error("Error() should be non-empty for an error status")
}
}
func TestReadStatusTruncated(t *testing.T) {
if _, err := ReadStatus(bytes.NewReader(nil), binary.LittleEndian); err == nil {
t.Error("empty status: want error")
}
// type byte present but the message string is missing.
if _, err := ReadStatus(bytes.NewReader([]byte{StatusError}), binary.LittleEndian); err == nil {
t.Error("status missing message: want error")
}
}
// ---- readPVAString ----------------------------------------------------
func TestReadPVAString(t *testing.T) {
// Short form.
short := append([]byte{0x03}, []byte("abc")...)
s, err := readPVAString(bytes.NewReader(short), binary.LittleEndian)
if err != nil || s != "abc" {
t.Errorf("short string: got %q err %v", s, err)
}
// Empty.
s, err = readPVAString(bytes.NewReader([]byte{0x00}), binary.LittleEndian)
if err != nil || s != "" {
t.Errorf("empty string: got %q err %v", s, err)
}
// Extended form: 0xFF + int32 length + bytes.
var ext bytes.Buffer
ext.WriteByte(0xFF)
_ = binary.Write(&ext, binary.LittleEndian, int32(5))
ext.WriteString("hello")
s, err = readPVAString(&ext, binary.LittleEndian)
if err != nil || s != "hello" {
t.Errorf("extended string: got %q err %v", s, err)
}
}
// ---- env --------------------------------------------------------------
func TestAddrsFromEnv(t *testing.T) {
t.Setenv("EPICS_PVA_ADDR_LIST", "")
if got := addrsFromEnv(); got != nil {
t.Errorf("empty env: got %v, want nil", got)
}
t.Setenv("EPICS_PVA_ADDR_LIST", " 1.2.3.4 5.6.7.8 ")
got := addrsFromEnv()
if len(got) != 2 || got[0] != "1.2.3.4" || got[1] != "5.6.7.8" {
t.Errorf("addr list: got %v", got)
}
}
+2
View File
@@ -3,7 +3,9 @@ package pvdata
import "io"
// BitSet is a variable-length set of bit indices, encoded on the wire as:
//
// compact-size(n_bytes) + n_bytes of little-endian bits.
//
// Bit 0 of byte 0 is field index 0, bit 1 of byte 0 is field index 1, etc.
// Used in pvData Monitor responses for "changed" and "overrun" masks.
type BitSet struct {
+361
View File
@@ -0,0 +1,361 @@
package pvdata_test
import (
"bytes"
"math"
"reflect"
"testing"
"github.com/uopi/gopva/pvdata"
)
// ---- Scalar value codec: every type code -----------------------------
func TestScalarRoundTrip(t *testing.T) {
cases := []struct {
name string
tc byte
v pvdata.Value
}{
{"bool-true", pvdata.TypeCodeBoolean, true},
{"bool-false", pvdata.TypeCodeBoolean, false},
{"byte", pvdata.TypeCodeByte, int8(-12)},
{"short", pvdata.TypeCodeShort, int16(-3000)},
{"int", pvdata.TypeCodeInt, int32(-100000)},
{"long", pvdata.TypeCodeLong, int64(-1 << 40)},
{"ubyte", pvdata.TypeCodeUByte, uint8(200)},
{"ushort", pvdata.TypeCodeUShort, uint16(60000)},
{"uint", pvdata.TypeCodeUInt, uint32(4000000000)},
{"ulong", pvdata.TypeCodeULong, uint64(1 << 50)},
{"float", pvdata.TypeCodeFloat, float32(1.5)},
{"double", pvdata.TypeCodeDouble, float64(2.5)},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
desc := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: c.tc}
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, c.v); err != nil {
t.Fatalf("WriteValue: %v", err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatalf("ReadValue: %v", err)
}
if got != c.v {
t.Errorf("got %v (%T) want %v (%T)", got, got, c.v, c.v)
}
})
}
}
// ---- Scalar array codec: every array code ----------------------------
func TestScalarArrayRoundTrip(t *testing.T) {
cases := []struct {
name string
tc byte
v pvdata.Value
}{
{"bool", pvdata.TypeCodeBoolArr, []bool{true, false, true}},
{"byte", pvdata.TypeCodeByteArr, []int8{-1, 2, -3}},
{"short", pvdata.TypeCodeShortArr, []int16{-1, 2, 30000}},
{"int", pvdata.TypeCodeIntArr, []int32{-1, 2, 1 << 30}},
{"long", pvdata.TypeCodeLongArr, []int64{-1, 1 << 40}},
{"ubyte", pvdata.TypeCodeUByteArr, []uint8{1, 2, 255}},
{"ushort", pvdata.TypeCodeUShortArr, []uint16{1, 60000}},
{"uint", pvdata.TypeCodeUIntArr, []uint32{1, 4000000000}},
{"ulong", pvdata.TypeCodeULongArr, []uint64{1, 1 << 50}},
{"float", pvdata.TypeCodeFloatArr, []float32{1.5, -2.25}},
{"double", pvdata.TypeCodeDoubleArr, []float64{1.5, -2.25}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
desc := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: c.tc}
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, c.v); err != nil {
t.Fatalf("WriteValue: %v", err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatalf("ReadValue: %v", err)
}
if !reflect.DeepEqual(got, c.v) {
t.Errorf("got %v want %v", got, c.v)
}
})
}
}
func TestStringArrayRoundTrip(t *testing.T) {
desc := pvdata.FieldDesc{Kind: pvdata.KindStringArr, TypeCode: pvdata.TypeCodeStringArr}
for _, v := range [][]string{{"a", "", "epics"}, {}} {
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, v); err != nil {
t.Fatalf("WriteValue: %v", err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatalf("ReadValue: %v", err)
}
gs := got.([]string)
if len(gs) != len(v) {
t.Fatalf("len: got %d want %d", len(gs), len(v))
}
for i := range v {
if gs[i] != v[i] {
t.Errorf("[%d]: got %q want %q", i, gs[i], v[i])
}
}
}
}
// ---- FieldDesc round-trip for every Kind -----------------------------
func TestFieldDescVariants(t *testing.T) {
descs := []pvdata.FieldDesc{
{TypeCode: pvdata.TypeCodeNull},
{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString},
{Kind: pvdata.KindStringArr, TypeCode: pvdata.TypeCodeStringArr},
{Kind: pvdata.KindVariant, TypeCode: pvdata.TypeCodeVariant},
{Kind: pvdata.KindScalarArr, TypeCode: pvdata.TypeCodeDoubleArr},
{Kind: pvdata.KindUnion, TypeCode: pvdata.TypeCodeUnion, TypeID: "u", Fields: []pvdata.Field{
{Name: "a", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
}},
{Kind: pvdata.KindStructArr, TypeCode: pvdata.TypeCodeStructArr, TypeID: "s", Fields: []pvdata.Field{
{Name: "x", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}},
}},
{Kind: pvdata.KindUnionArr, TypeCode: pvdata.TypeCodeUnionArr, TypeID: "ua", Fields: []pvdata.Field{
{Name: "y", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
}},
}
for _, d := range descs {
var buf bytes.Buffer
if err := pvdata.WriteFieldDesc(&buf, d); err != nil {
t.Fatalf("WriteFieldDesc(%#x): %v", d.TypeCode, err)
}
got, err := pvdata.ReadFieldDesc(&buf)
if err != nil {
t.Fatalf("ReadFieldDesc(%#x): %v", d.TypeCode, err)
}
if got.TypeCode != d.TypeCode {
t.Errorf("TypeCode: got %#x want %#x", got.TypeCode, d.TypeCode)
}
if got.TypeID != d.TypeID {
t.Errorf("TypeID: got %q want %q", got.TypeID, d.TypeID)
}
if len(got.Fields) != len(d.Fields) {
t.Errorf("Fields: got %d want %d", len(got.Fields), len(d.Fields))
}
}
}
// ---- Struct array value (readStructArr) ------------------------------
func TestStructArrRoundTrip(t *testing.T) {
fields := []pvdata.Field{
{Name: "x", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
}
elems := []pvdata.StructValue{
{TypeID: "p", Fields: []pvdata.FieldValue{{Name: "x", Value: int32(1)}}},
{TypeID: "p", Fields: []pvdata.FieldValue{{Name: "x", Value: int32(2)}}},
}
var buf bytes.Buffer
if err := pvdata.WriteSize(&buf, int64(len(elems))); err != nil {
t.Fatal(err)
}
sdesc := pvdata.FieldDesc{Kind: pvdata.KindStruct, TypeCode: pvdata.TypeCodeStruct, TypeID: "p", Fields: fields}
for _, sv := range elems {
if err := pvdata.WriteValue(&buf, sdesc, sv); err != nil {
t.Fatal(err)
}
}
adesc := pvdata.FieldDesc{Kind: pvdata.KindStructArr, TypeCode: pvdata.TypeCodeStructArr, TypeID: "p", Fields: fields}
got, err := pvdata.ReadValue(&buf, adesc)
if err != nil {
t.Fatal(err)
}
arr := got.([]pvdata.StructValue)
if len(arr) != 2 || arr[0].Fields[0].Value.(int32) != 1 || arr[1].Fields[0].Value.(int32) != 2 {
t.Errorf("got %+v", arr)
}
}
// ---- Union value (readUnion / writeUnion) ----------------------------
func unionDesc() pvdata.FieldDesc {
return pvdata.FieldDesc{
Kind: pvdata.KindUnion, TypeCode: pvdata.TypeCodeUnion, TypeID: "u",
Fields: []pvdata.Field{
{Name: "i", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
{Name: "s", Desc: pvdata.FieldDesc{Kind: pvdata.KindString, TypeCode: pvdata.TypeCodeString}},
},
}
}
func TestUnionSelectedValue(t *testing.T) {
desc := unionDesc()
// Manually encode: select field 1 (string) with value "hi".
var buf bytes.Buffer
if err := pvdata.WriteSize(&buf, 1); err != nil {
t.Fatal(err)
}
if err := pvdata.WriteValue(&buf, desc.Fields[1].Desc, "hi"); err != nil {
t.Fatal(err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatal(err)
}
uv := got.(pvdata.UnionValue)
if uv.Selected != 1 || uv.Name != "s" || uv.Value.(string) != "hi" {
t.Errorf("got %+v", uv)
}
}
func TestUnionNullSelector(t *testing.T) {
desc := unionDesc()
// writeUnion only writes the selector; an out-of-range selector → null.
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, pvdata.UnionValue{Selected: 7}); err != nil {
t.Fatal(err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatal(err)
}
uv := got.(pvdata.UnionValue)
if uv.Selected != 7 || uv.Value != nil {
t.Errorf("got %+v, want null selector", uv)
}
}
func TestUnionArrRoundTrip(t *testing.T) {
desc := pvdata.FieldDesc{
Kind: pvdata.KindUnionArr, TypeCode: pvdata.TypeCodeUnionArr, TypeID: "u",
Fields: []pvdata.Field{
{Name: "i", Desc: pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeInt}},
},
}
var buf bytes.Buffer
if err := pvdata.WriteSize(&buf, 2); err != nil {
t.Fatal(err)
}
for _, val := range []int32{10, 20} {
if err := pvdata.WriteSize(&buf, 0); err != nil { // selector 0
t.Fatal(err)
}
if err := pvdata.WriteValue(&buf, desc.Fields[0].Desc, val); err != nil {
t.Fatal(err)
}
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatal(err)
}
arr := got.([]pvdata.UnionValue)
if len(arr) != 2 || arr[0].Value.(int32) != 10 || arr[1].Value.(int32) != 20 {
t.Errorf("got %+v", arr)
}
}
// ---- Variant value (readVariant + WriteValue variant path) -----------
func TestVariantRoundTrip(t *testing.T) {
inner := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: pvdata.TypeCodeDouble}
vv := pvdata.VariantValue{Desc: inner, Value: float64(9.5)}
desc := pvdata.FieldDesc{Kind: pvdata.KindVariant, TypeCode: pvdata.TypeCodeVariant}
var buf bytes.Buffer
if err := pvdata.WriteValue(&buf, desc, vv); err != nil {
t.Fatal(err)
}
got, err := pvdata.ReadValue(&buf, desc)
if err != nil {
t.Fatal(err)
}
gvv := got.(pvdata.VariantValue)
if gvv.Value.(float64) != 9.5 || gvv.Desc.TypeCode != pvdata.TypeCodeDouble {
t.Errorf("got %+v", gvv)
}
}
// ---- Compact size: extended (>MaxInt32) encodings --------------------
func TestCompactSizeLarge(t *testing.T) {
cases := []int64{math.MaxInt32, math.MaxInt32 + 1, 1 << 40}
for _, n := range cases {
var buf bytes.Buffer
if err := pvdata.WriteSize(&buf, n); err != nil {
t.Fatalf("WriteSize(%d): %v", n, err)
}
got, err := pvdata.ReadSize(&buf)
if err != nil {
t.Fatalf("ReadSize(%d): %v", n, err)
}
if got != n {
t.Errorf("round-trip size %d → %d", n, got)
}
}
}
// ---- Error / truncation paths ----------------------------------------
func TestReadErrors(t *testing.T) {
if _, err := pvdata.ReadSize(bytes.NewReader(nil)); err == nil {
t.Error("ReadSize on empty: want error")
}
if _, err := pvdata.ReadString(bytes.NewReader([]byte{0x05})); err == nil {
t.Error("ReadString truncated: want error")
}
if _, err := pvdata.ReadFieldDesc(bytes.NewReader(nil)); err == nil {
t.Error("ReadFieldDesc on empty: want error")
}
// Unknown scalar type code.
badScalar := pvdata.FieldDesc{Kind: pvdata.KindScalar, TypeCode: 0x99}
if _, err := pvdata.ReadValue(bytes.NewReader([]byte{1, 2, 3, 4}), badScalar); err == nil {
t.Error("ReadValue unknown scalar code: want error")
}
// Unknown array type code (after a valid size byte).
badArr := pvdata.FieldDesc{Kind: pvdata.KindScalarArr, TypeCode: 0x99}
if _, err := pvdata.ReadValue(bytes.NewReader([]byte{0x01}), badArr); err == nil {
t.Error("ReadValue unknown array code: want error")
}
// Unknown kind.
if _, err := pvdata.ReadValue(bytes.NewReader(nil), pvdata.FieldDesc{Kind: pvdata.Kind(99)}); err == nil {
t.Error("ReadValue unknown kind: want error")
}
// WriteValue unsupported kind (struct array has no writer).
if err := pvdata.WriteValue(&bytes.Buffer{}, pvdata.FieldDesc{Kind: pvdata.KindStructArr}, nil); err == nil {
t.Error("WriteValue unsupported kind: want error")
}
}
// ---- BitSet edges ----------------------------------------------------
func TestBitSetEdges(t *testing.T) {
bs := pvdata.NewBitSet()
if bs.Has(0) {
t.Error("fresh NewBitSet should be empty")
}
bs.Set(0)
bs.Set(5)
bs.Set(12)
if got := bs.FieldIndices(); !reflect.DeepEqual(got, []int{0, 5, 12}) {
t.Errorf("FieldIndices: got %v want [0 5 12]", got)
}
if bs.Has(100) { // far out of range → no panic, false
t.Error("Has(100) should be false")
}
// Empty BitSet round-trips as a zero-length payload.
var buf bytes.Buffer
if err := pvdata.WriteBitSet(&buf, pvdata.NewBitSet()); err != nil {
t.Fatal(err)
}
got, err := pvdata.ReadBitSet(&buf)
if err != nil {
t.Fatal(err)
}
if len(got.FieldIndices()) != 0 {
t.Errorf("empty BitSet round-trip: got %v", got.FieldIndices())
}
}
+1
View File
@@ -293,6 +293,7 @@ func WriteFieldDesc(w io.Writer, fd FieldDesc) error {
// callers type-assert to concrete types listed below.
//
// Scalar types:
//
// bool, int8, int16, int32, int64,
// uint8, uint16, uint32, uint64,
// float32, float64, string
+157 -15
View File
@@ -2,6 +2,14 @@
listen = ":8080"
storage_dir = "./interfaces"
# ── UI presentation defaults (sent to the browser) ──────────────────────────
# default_zoom is the base UI scale used when a browser has no saved zoom of its
# own. Handy for HiDPI screens (e.g. 4K) whose OS scaling is left at 100%, where
# the browser reports devicePixelRatio=1 and the UI would otherwise be tiny. The
# in-app A+/A control still overrides it per browser. 1.0 = no scaling.
[ui]
default_zoom = 1.0 # e.g. 1.5 or 1.75 on dense displays
# ── Identity & access control ───────────────────────────────────────────────
# The end-user identity is read from a header set by a trusted authenticating
# reverse proxy. Leave trusted_user_header empty for unproxied/dev/LAN use.
@@ -11,23 +19,106 @@ trusted_user_header = "" # e.g. "X-Forwarded-User"
# Identity used when the trusted header is absent/empty. Empty = anonymous.
default_user = ""
# Every user is trusted with full write access by default. The blacklist
# downgrades specific users. Levels: "readonly" (view only, no writes) or
# "noaccess" (denied entirely).
# [[server.blacklist]]
# user = "guest"
# level = "readonly"
# ── Native Kerberos / SPNEGO authentication ─────────────────────────────────
# Alternative to a reverse proxy: uopi authenticates browsers directly via
# SPNEGO ("Negotiate"). Recommended when proxy-header auth lets some browsers
# (notably Firefox) silently fall through to default_user. When enabled, uopi
# challenges API requests with 401 WWW-Authenticate: Negotiate and resolves the
# user from the validated Kerberos ticket (short principal name, realm stripped);
# any inbound trusted_user_header value is ignored to prevent spoofing.
# Browsers must be told to perform SPNEGO for this origin
# (Firefox: network.negotiate-auth.trusted-uris).
[server.kerberos]
enabled = false
# Path to the service keytab holding the HTTP service principal's key
# (e.g. HTTP/host.example.com@REALM). Required when enabled.
keytab = "" # e.g. "/etc/uopi/http.keytab"
# Optionally pin which keytab principal to accept tickets for. Empty = default.
service_principal = "" # e.g. "HTTP/host.example.com"
# Named sets of users, referenced by per-panel sharing rules.
# ── Built-in HTTP Basic authentication (PAM) ────────────────────────────────
# Another standalone alternative (no reverse proxy, no Kerberos): uopi challenges
# with 401 WWW-Authenticate: Basic and validates the username/password through the
# host PAM stack (/etc/pam.d/<pam_service>). On an SSSD/LDAP-joined host this
# reuses each user's normal login password with no directory config in uopi. The
# validated username feeds the same access pipeline. Mutually exclusive with
# Kerberos. REQUIRES a PAM-enabled build: `make backend-pam` (cgo + libpam); the
# default static binary cannot validate and refuses to start with this enabled.
# Basic credentials travel on every request — enable [server.tls] below unless on
# a fully isolated network.
[server.basic_auth]
enabled = false
# PAM service name under /etc/pam.d/. Empty defaults to "uopi".
pam_service = "" # e.g. "uopi" or "login"
# ── Built-in HTTP Basic authentication (LDAP) ───────────────────────────────
# Pure-Go alternative to basic_auth/PAM: validates the Basic credentials directly
# against an LDAP directory using "search then bind" — the same flow an SSSD/LDAP
# client uses. Needs NO cgo, so it works in the default fully-static binary
# (`make release`), unlike PAM. Mutually exclusive with kerberos and basic_auth.
# Defaults mirror SSSD: user_attr → "uid", user_object_class → "posixAccount",
# empty bind_dn → anonymous search. Enable TLS below (or use ldaps://) so
# passwords are not sent in clear text.
[server.ldap]
enabled = false
# Directory endpoint(s), tried in order (SSSD ldap_uri).
uri = ["ldaps://ldap.example.com"]
# Subtree user entries live under (SSSD ldap_search_base).
search_base = "dc=example,dc=com"
# Attribute matched against the login name. Empty defaults to "uid".
user_attr = "" # e.g. "uid" (RFC2307) or "sAMAccountName" (AD)
# Object class to restrict the search. Empty defaults to "posixAccount".
user_object_class = "" # e.g. "posixAccount" or "user" (AD)
# Optional service account for the search. Empty bind_dn = anonymous search.
bind_dn = ""
bind_password = ""
# Upgrade a plain ldap:// connection to TLS before binding (ignored for ldaps://).
start_tls = false
# Optional PEM CA bundle to trust a private directory CA.
ca_cert = "" # e.g. "/etc/uopi/ldap-ca.pem"
# Disable TLS certificate verification (testing only — insecure).
insecure_skip_verify = false
# ── Built-in TLS / HTTPS ────────────────────────────────────────────────────
# Terminate HTTPS directly without a reverse proxy. Strongly recommended whenever
# basic_auth is enabled. Cert and key are PEM paths; both required when enabled.
[server.tls]
enabled = false
cert = "" # e.g. "/etc/uopi/tls/cert.pem"
key = "" # e.g. "/etc/uopi/tls/key.pem"
# Optionally run a plain-HTTP listener that 301-redirects visitors to the HTTPS
# service, so users who type http:// (or omit the scheme) are upgraded instead of
# hitting the TLS port with a cleartext request ("client sent an HTTP request to
# an HTTPS server"). Empty = disabled.
redirect_from = "" # e.g. ":80"
# Role-based access through group memberships. Each [[groups]] block lists
# members by role; a user's effective global capability is the highest role
# across all their memberships, along this ladder:
# viewer < operator < logiceditor < auditor < admin
# Capabilities: operator+ may write signals; logiceditor+ may edit panel and
# control logic; auditor+ may view the audit log; admin may open the admin pane.
#
# The built-in "public" group is implicit (every user is a viewer of it). A
# config with NO roles assigned anywhere is fully open (everyone is admin), for
# trusted-LAN/dev use; assigning any role switches to strict mode where unlisted
# users are read-only viewers. Once changed at runtime via the admin pane,
# {storage_dir}/access.json supersedes the groups below.
#
# Groups may nest via "parent": a member of a parent group inherits its role on
# every descendant group too (unless overridden lower).
# [[groups]]
# name = "operators"
# members = ["alice", "bob"]
# Optional: restrict who may add or edit panel logic (the <logic> block of
# interfaces) AND server-side control logic. Entries are usernames or group
# names. Empty/unset = no restriction (any writer may edit logic). Also settable
# via UOPI_SERVER_LOGIC_EDITORS (space-separated).
# logic_editors = ["operators", "alice"]
# name = "public"
# admins = ["alice"] # alice is a global admin
# [[groups]]
# name = "operations"
# operators = ["bob"]
# auditors = ["carol"]
# [[groups]]
# name = "engineers"
# parent = "operations" # bob inherits operator here
# logiceditors = ["dave"]
# viewers = ["erin"]
[datasource.epics]
enabled = true
@@ -38,3 +129,54 @@ auto_sync_filter = "" # e.g. area=StorageRing or tags=production
[datasource.synthetic]
enabled = true
# Modbus TCP. Each device is polled over its own connection; registers become
# signals named "device:register". Disabled unless enabled = true and at least
# one device is declared.
[datasource.modbus]
enabled = false
poll_interval_ms = 1000 # default poll period for every register
# [[datasource.modbus.devices]]
# name = "plc1"
# address = "192.168.1.50:502" # ":502" appended if no port given
# unit_id = 1
# timeout_ms = 3000
#
# [[datasource.modbus.devices.registers]]
# name = "temperature"
# kind = "holding" # holding | input | coil | discrete
# address = 100
# encoding = "int16" # uint16|int16|uint32|int32|float32|float64
# word_order = "big" # big (default) | little, for multi-word encodings
# unit = "C"
# scale = 0.1 # value = raw*scale + offset (scale 0 → 1)
# offset = 0.0
# min = -50
# max = 200
# writable = false # input/discrete are always read-only
# description = "Process temperature"
# SCPI instruments over raw TCP sockets (line-based "SCPI raw", port 5025).
# Channels become signals named "instrument:channel". A vxi11 transport is
# reserved for a future release.
[datasource.scpi]
enabled = false
poll_interval_ms = 1000
# [[datasource.scpi.instruments]]
# name = "dmm1"
# transport = "raw" # raw (default); vxi11 reserved
# address = "192.168.1.60:5025" # ":5025" appended if no port given
# timeout_ms = 3000
# terminator = "\n"
#
# [[datasource.scpi.instruments.channels]]
# name = "voltage"
# query = "MEAS:VOLT?" # SCPI query whose response is the value
# write_cmd = "VOLT %v" # printf template; omit for read-only
# type = "float" # float (default) | int | string | bool
# unit = "V"
# min = 0
# max = 30
# description = "DC voltage"
+487
View File
@@ -0,0 +1,487 @@
import { h } from 'preact';
import { useState, useEffect, useCallback, useMemo } from 'preact/hooks';
// ── Types (mirror internal/access AccessSnapshot + the /admin/stats payload) ──
interface MemberInfo { user: string; role: string; }
interface GroupInfo {
name: string;
parent: string;
members: MemberInfo[];
}
interface UserInfo {
name: string;
effectiveRole: string;
roles: Record<string, string>; // group → role token
}
interface AccessSnapshot {
defaultUser: string;
publicGroup: string;
roles: string[]; // role ladder, low → high
configured: boolean;
users: UserInfo[];
groups: GroupInfo[];
}
interface ServerStats {
uptimeSeconds: number;
wsConnections: number;
observedSignals: number;
msgIn: number;
msgOut: number;
writeOps: number;
historyReqs: number;
goroutines: number;
memAllocBytes: number;
memSysBytes: number;
heapInuseBytes: number;
loadAvg: number[] | null;
dataSources: string[];
}
interface Props { onClose: () => void; }
const msg = (err: unknown): string => (err instanceof Error ? err.message : String(err));
async function apiJSON<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();
}
const jsonPut = (body: any): RequestInit => ({ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const jsonPost = (body: any): RequestInit => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const ROLE_LABELS: Record<string, string> = {
viewer: 'Viewer',
operator: 'Operator',
logiceditor: 'Logic editor',
auditor: 'Auditor',
admin: 'Admin',
};
const roleLabel = (r: string): string => ROLE_LABELS[r] ?? r;
/** Order group names so children follow their parent, and compute nesting depth. */
function orderGroups(groups: GroupInfo[]): { group: GroupInfo; depth: number }[] {
const byParent = new Map<string, GroupInfo[]>();
for (const g of groups) {
const key = g.parent || '';
(byParent.get(key) ?? byParent.set(key, []).get(key)!).push(g);
}
for (const list of byParent.values()) list.sort((a, b) => a.name.localeCompare(b.name));
const out: { group: GroupInfo; depth: number }[] = [];
const names = new Set(groups.map(g => g.name));
const walk = (parent: string, depth: number) => {
for (const g of byParent.get(parent) ?? []) {
out.push({ group: g, depth });
walk(g.name, depth + 1);
}
};
walk('', 0);
// Orphans (parent missing) rendered at root.
for (const g of groups) {
if (g.parent && !names.has(g.parent) && !out.some(o => o.group.name === g.name)) {
out.push({ group: g, depth: 0 });
}
}
return out;
}
export default function AdminPane({ onClose }: Props) {
const [tab, setTab] = useState<'users' | 'groups' | 'stats'>('users');
const [snap, setSnap] = useState<AccessSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setError(null);
try {
setSnap(await apiJSON<AccessSnapshot>('/api/v1/admin/access'));
} catch (err) { setError(msg(err)); }
}, []);
useEffect(() => { load(); }, [load]);
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal cl-modal-full">
<header class="cl-header">
<span class="cl-title">Admin</span>
<span class="hint cl-subtitle">Manage role-based access through group memberships, and view live server statistics.</span>
<div class="cl-header-actions">
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="cfg-tabs">
<button class={`cfg-tab${tab === 'users' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('users')}>Users</button>
<button class={`cfg-tab${tab === 'groups' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('groups')}>Groups</button>
<button class={`cfg-tab${tab === 'stats' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('stats')}>Server stats</button>
</div>
{error && <div class="cl-error">{error}</div>}
{snap && !snap.configured && tab !== 'stats' && (
<div class="hint admin-banner">
No roles assigned yet access is fully open (everyone is admin). Assigning any role
switches to strict mode where unlisted users are read-only viewers.
</div>
)}
{tab === 'users' && snap && <UsersManager snap={snap} onSnap={setSnap} onError={setError} />}
{tab === 'groups' && snap && <GroupsManager snap={snap} onSnap={setSnap} onError={setError} />}
{tab === 'stats' && <StatsView onError={setError} />}
</div>
</div>
);
}
interface ManagerProps {
snap: AccessSnapshot;
onSnap: (s: AccessSnapshot) => void;
onError: (e: string | null) => void;
}
// ── Users ─────────────────────────────────────────────────────────────────────
function UsersManager({ snap, onSnap, onError }: ManagerProps) {
const [selected, setSelected] = useState<string | null>(null);
// Draft maps group → role token for the selected user (only groups with a role).
const [draft, setDraft] = useState<Record<string, string> | null>(null);
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const ordered = useMemo(() => orderGroups(snap.groups), [snap.groups]);
function select(name: string) {
const u = snap.users.find(x => x.name === name);
setSelected(name);
setDraft({ ...(u?.roles ?? {}) });
}
function addUser() {
const n = newName.trim();
if (!n) return;
setNewName('');
select(n);
}
const setGroupRole = (group: string, role: string) => setDraft(d => {
const next = { ...(d ?? {}) };
if (role === '') delete next[group];
else next[group] = role;
return next;
});
async function save() {
if (!draft || !selected) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/users/${encodeURIComponent(selected)}`, jsonPut({ roles: draft }));
if (updated) onSnap(updated);
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
const effective = selected ? snap.users.find(u => u.name === selected)?.effectiveRole : undefined;
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<input class="audit-filter" type="text" placeholder="Add user…" value={newName}
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') addUser(); }} />
<button class="panel-btn" onClick={addUser}>Add</button>
</div>
{snap.users.length === 0 && <div class="hint cl-list-empty">No users with explicit roles. Everyone is a viewer of the public group.</div>}
{snap.users.map(u => (
<div key={u.name} class={`cl-list-item${selected === u.name ? ' cl-list-item-active' : ''}`} onClick={() => select(u.name)}>
<span class="cl-list-name" title={u.name}>{u.name}</span>
<span class={`admin-role-tag admin-role-${u.effectiveRole}`}>{roleLabel(u.effectiveRole)}</span>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!draft && <div class="hint admin-empty">Select a user, or add one, to assign roles per group.</div>}
{draft && (
<div class="admin-editor">
<h3 class="admin-editor-title">
{selected}
{effective && <span class={`admin-role-tag admin-role-${effective}`}>{roleLabel(effective)}</span>}
</h3>
<p class="hint">Effective access is the highest role across all groups. Every user is at least a viewer via the public group.</p>
<label class="admin-field-label">Role per group</label>
<div class="admin-role-table">
{ordered.map(({ group, depth }) => (
<div key={group.name} class="admin-role-row">
<span class="admin-role-group" style={{ paddingLeft: `${depth * 1.1}rem` }}>
{depth > 0 && <span class="admin-tree-mark"> </span>}
{group.name}
{group.name === snap.publicGroup && <span class="hint"> (everyone)</span>}
</span>
<select class="prop-select admin-role-select"
value={draft[group.name] ?? ''}
onChange={(e) => setGroupRole(group.name, (e.target as HTMLSelectElement).value)}>
<option value=""> none </option>
{snap.roles.map(r => <option key={r} value={r}>{roleLabel(r)}</option>)}
</select>
</div>
))}
</div>
<div class="admin-editor-actions">
<button class="panel-btn" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
</div>
</div>
)}
</div>
</div>
);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function descendantsOf(groups: GroupInfo[], name: string): Set<string> {
const out = new Set<string>();
const walk = (parent: string) => {
for (const g of groups) {
if (g.parent === parent && !out.has(g.name)) {
out.add(g.name);
walk(g.name);
}
}
};
walk(name);
return out;
}
function GroupsManager({ snap, onSnap, onError }: ManagerProps) {
const [selected, setSelected] = useState<string | null>(null);
const [name, setName] = useState('');
const [parent, setParent] = useState('');
const [members, setMembers] = useState<MemberInfo[]>([]);
const [newMember, setNewMember] = useState('');
const [newGroup, setNewGroup] = useState('');
const [busy, setBusy] = useState(false);
const ordered = useMemo(() => orderGroups(snap.groups), [snap.groups]);
const isPublic = selected === snap.publicGroup;
function select(gname: string) {
const g = snap.groups.find(x => x.name === gname);
if (!g) return;
setSelected(gname);
setName(g.name);
setParent(g.parent);
setMembers(g.members.map(m => ({ ...m })));
setNewMember('');
}
// Parent options exclude the group itself and its descendants (cycle guard) and public.
const parentOptions = useMemo(() => {
if (!selected) return [];
const blocked = descendantsOf(snap.groups, selected);
blocked.add(selected);
return snap.groups.filter(g => !blocked.has(g.name)).map(g => g.name);
}, [snap.groups, selected]);
async function create() {
const n = newGroup.trim();
if (!n) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>('/api/v1/admin/groups', jsonPost({ name: n }));
if (updated) onSnap(updated);
setNewGroup('');
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
function addMember() {
const u = newMember.trim();
if (!u || members.some(m => m.user === u)) { setNewMember(''); return; }
setMembers(ms => [...ms, { user: u, role: 'operator' }]);
setNewMember('');
}
const setMemberRole = (user: string, role: string) =>
setMembers(ms => ms.map(m => m.user === user ? { ...m, role } : m));
const removeMember = (user: string) => setMembers(ms => ms.filter(m => m.user !== user));
async function save() {
if (!selected) return;
setBusy(true);
onError(null);
try {
const memberMap: Record<string, string> = {};
for (const m of members) memberMap[m.user] = m.role;
const body = { name: name.trim() || selected, parent: isPublic ? '' : parent, members: memberMap };
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/groups/${encodeURIComponent(selected)}`, jsonPut(body));
if (updated) { onSnap(updated); setSelected(body.name); }
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
async function remove() {
if (!selected || isPublic || !confirm(`Delete group "${selected}"? Members keep their other groups.`)) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/groups/${encodeURIComponent(selected)}`, { method: 'DELETE' });
if (updated) onSnap(updated);
setSelected(null);
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<input class="audit-filter" type="text" placeholder="New group…" value={newGroup}
onInput={(e) => setNewGroup((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') create(); }} />
<button class="panel-btn" disabled={busy} onClick={create}>Create</button>
</div>
{ordered.map(({ group, depth }) => (
<div key={group.name} class={`cl-list-item${selected === group.name ? ' cl-list-item-active' : ''}`} onClick={() => select(group.name)}>
<span class="cl-list-name" title={group.name} style={{ paddingLeft: `${depth * 0.9}rem` }}>
{depth > 0 && <span class="admin-tree-mark"> </span>}
{group.name}
</span>
<span class="hint">{group.members.length}</span>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!selected && <div class="hint admin-empty">Select a group, or create one, to manage its parent and member roles.</div>}
{selected && (
<div class="admin-editor">
<label class="admin-field-label">Name</label>
<input class="admin-input" type="text" value={name} disabled={isPublic}
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
{isPublic && <p class="hint">The built-in public group cannot be renamed, reparented or deleted; every user is an implicit viewer member.</p>}
<label class="admin-field-label">Parent group (nesting)</label>
<select class="prop-select admin-input" value={parent} disabled={isPublic}
onChange={(e) => setParent((e.target as HTMLSelectElement).value)}>
<option value=""> none (top level) </option>
{parentOptions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
{!isPublic && <p class="hint">Members of a parent group inherit their role on this group too, unless overridden here.</p>}
<label class="admin-field-label">Members</label>
<div class="admin-member-add">
<input class="admin-input" type="text" placeholder="Add member…" value={newMember}
onInput={(e) => setNewMember((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') addMember(); }} />
<button class="panel-btn" onClick={addMember}>Add</button>
</div>
{members.length === 0 && <div class="hint">No explicit members.</div>}
<div class="admin-role-table">
{members.map(m => (
<div key={m.user} class="admin-role-row">
<span class="admin-role-group">{m.user}</span>
<select class="prop-select admin-role-select" value={m.role}
onChange={(e) => setMemberRole(m.user, (e.target as HTMLSelectElement).value)}>
{snap.roles.map(r => <option key={r} value={r}>{roleLabel(r)}</option>)}
</select>
<button class="admin-row-remove" title="Remove member" onClick={() => removeMember(m.user)}></button>
</div>
))}
</div>
<div class="admin-editor-actions">
<button class="panel-btn" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
{!isPublic && <button class="panel-btn" disabled={busy} onClick={remove}>Delete</button>}
</div>
</div>
)}
</div>
</div>
);
}
// ── Server stats ─────────────────────────────────────────────────────────────
function fmtUptime(s: number): string {
s = Math.floor(s);
const d = Math.floor(s / 86400); s -= d * 86400;
const h = Math.floor(s / 3600); s -= h * 3600;
const m = Math.floor(s / 60); s -= m * 60;
const parts: string[] = [];
if (d) parts.push(`${d}d`);
if (h || d) parts.push(`${h}h`);
parts.push(`${m}m`, `${s}s`);
return parts.join(' ');
}
const fmtMB = (b: number): string => `${(b / (1024 * 1024)).toFixed(1)} MB`;
function StatsView({ onError }: { onError: (e: string | null) => void }) {
const [stats, setStats] = useState<ServerStats | null>(null);
useEffect(() => {
let live = true;
const load = async () => {
try {
const s = await apiJSON<ServerStats>('/api/v1/admin/stats');
if (live && s) setStats(s);
} catch (err) { if (live) onError(msg(err)); }
};
load();
const t = setInterval(load, 2000);
return () => { live = false; clearInterval(t); };
}, [onError]);
if (!stats) return <div class="cl-body"><div class="hint admin-empty">Loading</div></div>;
const rows: [string, string][] = [
['Uptime', fmtUptime(stats.uptimeSeconds)],
['WebSocket connections', String(stats.wsConnections)],
['Observed signals', String(stats.observedSignals)],
['Messages in', String(stats.msgIn)],
['Messages out', String(stats.msgOut)],
['Signal writes', String(stats.writeOps)],
['History requests', String(stats.historyReqs)],
['Goroutines', String(stats.goroutines)],
['Memory allocated', fmtMB(stats.memAllocBytes)],
['Memory from OS', fmtMB(stats.memSysBytes)],
['Heap in use', fmtMB(stats.heapInuseBytes)],
['Load average', stats.loadAvg ? stats.loadAvg.map(n => n.toFixed(2)).join(' / ') : '—'],
];
return (
<div class="cl-body">
<div class="admin-stats">
<table class="audit-table admin-stats-table">
<tbody>
{rows.map(([k, v]) => (
<tr key={k}><th class="admin-stat-key">{k}</th><td class="admin-stat-val">{v}</td></tr>
))}
</tbody>
</table>
<div class="admin-stats-ds">
<h4 class="admin-field-label">Data sources ({stats.dataSources.length})</h4>
<div class="admin-ds-list">
{stats.dataSources.map(d => <span key={d} class="admin-ds-tag">{d}</span>)}
</div>
</div>
</div>
</div>
);
}
+8 -5
View File
@@ -5,7 +5,7 @@ import ViewMode from './ViewMode';
import EditMode from './EditMode';
import FullscreenMode from './FullscreenMode';
import ControlDialogs from './ControlDialogs';
import { applyZoom, getStoredZoom } from './ZoomControl';
import { applyZoom, getStoredZoom, setServerDefaultZoom, watchDpr } from './ZoomControl';
import { AuthContext, DEFAULT_ME, fetchMe, canRead } from './lib/auth';
import type { Interface, Me } from './lib/types';
@@ -29,11 +29,14 @@ export default function App() {
}, []);
useEffect(() => {
const dpr = window.devicePixelRatio ?? 1;
document.documentElement.style.setProperty('--dpr', String(dpr));
applyZoom(getStoredZoom());
applyZoom(getStoredZoom(), false);
const unwatchDpr = watchDpr();
if (!fsParam) wsClient.connect('/ws');
fetchMe().then(setMe);
fetchMe().then(me => {
setMe(me);
setServerDefaultZoom(me.defaultZoom);
});
return unwatchDpr;
}, []);
if (fsParam) {

Some files were not shown because too many files have changed in this diff Show More