50 KiB
Technical Specification — uopi
1. Technology Choices
1.1 Backend — Go
Rationale:
- Compiles to a single static binary with no runtime dependencies, trivially portable to old Linux targets.
//go:embedpacks the compiled frontend assets into the binary at build time.- Goroutine-per-connection model maps naturally onto the fan-out data broker pattern.
- CGo bindings to EPICS
libca/libComare straightforward. gopher-luaprovides an embedded Lua 5.1-compatible interpreter for synthetic signals with zero additional dependencies.- Strong standard library:
net/http,encoding/xml,encoding/json.
Go version: 1.22+
Key dependencies:
| Package | Purpose |
|---|---|
nhooyr.io/websocket |
WebSocket server (no CGo, more ergonomic than gorilla) |
CGo wrapper to libca |
EPICS Channel Access |
yuin/gopher-lua |
Lua 5.1 runtime for synthetic signals |
BurntSushi/toml |
TOML config parsing |
encoding/xml (stdlib) |
Interface file serialisation |
net/http (stdlib) |
HTTP server and static file serving |
1.2 Frontend — Preact + TypeScript
Rationale:
- Preact is a 3 kB React-compatible virtual DOM library — small bundle, fast diffing, no extra framework overhead.
- esbuild (invoked via its Go API) bundles the TypeScript/TSX source in milliseconds with no Node.js or npm dependency at build time.
- TypeScript catches signal subscription and widget property type errors at build time.
- All vendor JS/CSS is checked into
web/vendor/so the repo builds without internet access.
Key dependencies (vendored):
| Package | Purpose |
|---|---|
preact 10 |
Virtual DOM UI framework |
uPlot |
Extremely fast time-series/line plot (canvas-based, < 40 kB) |
Apache ECharts |
FFT, waterfall, histogram, bar, logic analyser, waveform plots |
uplot.css |
uPlot default stylesheet |
Intentionally excluded: React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
2. Repository Layout
uopi/
├── cmd/uopi/ # main package — CLI flags, wiring
├── internal/
│ ├── server/ # HTTP + WebSocket handlers
│ ├── broker/ # signal fan-out to clients
│ ├── datasource/
│ │ ├── iface.go # DataSource interface
│ │ ├── epics/ # EPICS CA implementation (CGo)
│ │ └── synthetic/ # synthetic signal engine + DSP bridge
│ ├── dsp/ # DSP node implementations (lowpass, MA, etc.)
│ ├── storage/ # interface XML read/write
│ └── api/ # REST handler functions
├── web/
│ ├── embed.go # //go:embed dist — exports FS to Go
│ ├── src/ # TypeScript/TSX source (Preact)
│ │ ├── lib/
│ │ │ ├── ws.ts # WebSocket client + subscription manager
│ │ │ ├── stores.ts # signal value + metadata stores
│ │ │ ├── types.ts # shared TypeScript interfaces
│ │ │ ├── xml.ts # interface XML parse/serialize
│ │ │ └── format.ts # value formatting helpers
│ │ ├── widgets/ # one .tsx file per widget type
│ │ ├── App.tsx # top-level component, mode routing
│ │ ├── ViewMode.tsx # view mode layout + tabs
│ │ ├── EditMode.tsx # edit mode layout + toolbar
│ │ ├── Canvas.tsx # live HMI canvas (view mode)
│ │ ├── EditCanvas.tsx # free-form widget editor canvas
│ │ ├── PlotPanel.tsx # live plot side-panel (Plot tab)
│ │ ├── InfoPanel.tsx # signal info side-panel
│ │ ├── ZoomControl.tsx # UI zoom A-/A+ control
│ │ ├── SyntheticWizard.tsx # new synthetic signal dialog
│ │ ├── SyntheticEditor.tsx # edit existing synthetic signal
│ │ ├── LuaEditor.tsx # Lua code editor with syntax highlight
│ │ └── styles.css # all component styles
│ ├── vendor/ # vendored JS/CSS (preact, uplot, echarts)
│ └── dist/ # built frontend — generated, not committed
├── tools/buildfrontend/ # esbuild Go API bundler (go generate)
├── docs/ # specs, work plan
├── CLAUDE.md
└── README.md
The embed package lives at web/embed.go (not in cmd/) because //go:embed paths cannot use ...
3. Backend Architecture
3.1 DataSource Interface
type Value struct {
Timestamp time.Time
Data any // float64 | []float64 | string | int64 | bool
Quality Quality // Good | Bad | Uncertain
}
type Metadata struct {
Name string
Type DataType
Unit string
DisplayLow float64
DisplayHigh float64
DriveHigh float64
DriveLow float64
EnumStrings []string
Writable bool
}
type DataSource interface {
Name() string
Connect(ctx context.Context) error
ListSignals(ctx context.Context) ([]Metadata, error)
GetMetadata(ctx context.Context, signal string) (Metadata, error)
Subscribe(ctx context.Context, signal string, ch chan<- Value) (CancelFunc, error)
Write(ctx context.Context, signal string, value any) error
History(ctx context.Context, signal string, start, end time.Time, maxPoints int) ([]Value, error)
}
New data sources are registered at startup via datasource.Register(name string, ds DataSource).
3.2 Signal Broker
The broker is the central fan-out component:
DataSource ──subscribe──► rawCh ──► Broker ──► [clientCh1, clientCh2, ...]
- One goroutine per active signal subscription to the underlying data source.
- Per-signal subscriber list protected by a sync.RWMutex.
- When the last client unsubscribes, the broker cancels the upstream subscription.
- No data is buffered in the broker; clients receive the latest value at the moment they subscribe and all subsequent updates.
3.3 WebSocket Protocol
Framing: JSON messages over a single persistent WebSocket connection per client.
Client → Server messages:
// Subscribe to one or more signals
{ "type": "subscribe", "signals": ["EPICS:PV1", "synth:mySignal"] }
// Unsubscribe
{ "type": "unsubscribe", "signals": ["EPICS:PV1"] }
// Write a value
{ "type": "write", "signal": "EPICS:PV1", "value": 3.14 }
// 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:
// Live value update
{ "type": "update", "signal": "EPICS:PV1", "ts": "2026-04-24T12:00:00.123Z", "value": 42.7, "quality": "good" }
// Metadata (sent once on first subscribe)
{ "type": "meta", "signal": "EPICS:PV1", "meta": { "unit": "A", "displayLow": 0, "displayHigh": 100, ... } }
// 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" }
3.4 REST API
Base path: /api/v1
| Method | Path | Description |
|---|---|---|
| GET | /me |
Caller identity, global level, groups, canEditLogic |
| GET | /datasources |
List connected data sources and their status |
| GET | /signals?ds=epics |
List signals for a data source |
| GET | /signals/search?q= |
Search signals across all sources |
| GET | /channel-finder?q= |
Proxy to EPICS Channel Finder |
| GET | /archiver/search?q= |
Search the EPICS archiver for PV names |
| GET, POST | /interfaces |
List saved interfaces / create one (body: XML) |
| POST | /interfaces/reorder |
Reorder panels / move between folders |
| GET, PUT, DELETE | /interfaces/{id} |
Download, update, or delete an interface |
| POST | /interfaces/{id}/clone |
Clone an interface |
| GET, PUT | /interfaces/{id}/acl |
Read or set a panel's sharing rules |
| GET, POST | /folders |
List or create panel folders |
| PUT, DELETE | /folders/{id} |
Rename/reparent or delete a folder |
| GET | /usergroups |
List configured users and groups (for sharing) |
| 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) |
| GET, PUT, DELETE | /config/sets/{id} |
Read, update, or delete a config set |
| GET, POST | /config/instances |
List or create configuration instances (values) |
| GET, PUT, DELETE | /config/instances/{id} |
Read, update, or delete a config instance |
| POST | /config/instances/{id}/apply |
Write an instance's values to their target signals |
| POST | /config/instances/{id}/validate |
Run the set's CUE rules over the stored values; returns a structured RuleResult (no save) |
| GET | /config/instances/{id}/livediff |
Diff the stored instance (resolved with defaults) against the current live signal values |
| POST | /config/sets/{id}/snapshot |
Capture every target signal's current live value into a new instance (body: optional {name}) |
| GET | /config/{sets|instances}/diff |
Structural diff between two revisions (a,av,b,bv) |
| GET, POST | /config/rules |
List or create CUE validation/transformation rules |
| GET, PUT, DELETE | /config/rules/{id} |
Read, update, or delete a rule |
| POST | /config/rules/check |
Compile an (unsaved) CUE source and evaluate it against sample values — powers the live editor |
Versioning (shared). Interfaces, synthetic signals, control-logic graphs and config
sets/instances all expose the same revision endpoints under their {base}:
| Method | Path | Description |
|---|---|---|
| GET | {base}/{id}/versions |
List revisions; …/{version} fetches one |
| POST | {base}/{id}/versions/{v}/promote |
Promote a revision to current |
| POST | {base}/{id}/versions/{v}/fork |
Fork a revision into a new document |
| PUT | /interfaces/{id}/versions/{v}/tag |
Tag a panel version |
Mutating requests are gated by the access middleware (§3.10): global level for writes,
per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic
endpoints and for any change to a panel's <logic> block. Config-set/instance
promote/fork are gated by the same write policy.
3.5 EPICS Data Source
- Uses CGo bindings to EPICS Base
libca(Channel Access). - Channel connections are lazy: connected on first Subscribe, disconnected when the broker releases it.
- On connect, a
ca_getretrieves full DBR_CTRL metadata (units, limits, enum strings). ca_add_eventsets up the monitor. Update callbacks push into the broker's raw channel.- Multiple PV subscriptions share one CA context per data source instance (thread-safe with
ca_attach_context). - EPICS Archive Appliance is queried via its JSON HTTP API for history requests.
3.6 Synthetic Data Source
- Each synthetic signal is defined as a directed acyclic graph (DAG) of processing nodes.
- Processing nodes are re-evaluated whenever any upstream signal emits a new value.
- Definitions are stored in a configurable JSON/TOML file alongside server configuration.
- The
dsp_bridge.gofile maps node type names todsp.Nodeimplementations.
Built-in node types:
| Node type | Parameters | In→Out | Description |
|---|---|---|---|
source |
ds, name |
— | Reads a signal from any data source |
gain |
gain |
elementwise | Multiplies by a constant |
offset |
offset |
elementwise | Adds a constant |
add/subtract |
— | elementwise | Sum of inputs / a − b |
multiply/divide |
— | elementwise | Product of inputs / a ÷ b |
clamp |
min, max |
elementwise | Constrains to a range |
threshold |
threshold, high, low |
elementwise | Comparator output |
moving_average |
window (samples) |
scalar-only | Rolling mean |
rms |
window (samples) |
scalar-only | Rolling RMS |
derivative |
— | scalar-only | Time derivative (per-sample dt) |
integrate |
— | scalar-only | Trapezoidal integral |
lowpass |
freq (Hz), order (1–8) |
scalar-only | Cascaded IIR Butterworth-style low-pass filter |
expr |
expr, vars |
elementwise | Inline math expression (named inputs) |
lua |
script, vars |
scalar-only | Arbitrary Lua 5.1 code with persistent state |
index |
i |
array→scalar | Element i of a waveform (bounds-checked) |
slice |
start, end |
array→array | Sub-range of a waveform (clamped) |
sum/mean |
— | array→scalar | Σ / average of a waveform |
min/max |
— | array→scalar | Reduction of a waveform |
length |
— | array→scalar | Element count of a waveform |
fft |
— | array→array | Magnitude spectrum (zero-padded to next pow-2) |
Scalar vs waveform values: A value flowing through the graph is a dsp.Sample — either a scalar float64 or a []float64 waveform (the array-aware counterpart of EPICS TypeFloat64Array). Elementwise ops broadcast over arrays (scalar inputs act as constants; array inputs must share a length). Reduction/producer ops (index/slice/sum/…/fft) operate natively on waveforms. Scalar-only ops (stateful filters + lua) reject array inputs, since their per-evaluation state cannot be split across array lanes. OpOutputType (internal/dsp/types.go) propagates types statically at compile time to reject invalid wirings and to report the synthetic's metadata type; the editor mirrors these rules in web/src/lib/synthTypes.ts to colour wires by data type (scalar vs array) and flag type-incompatible links. Runtime Sample typing is authoritative.
Low-pass filter implementation: Cascaded first-order IIR sections. Each stage computes y = y_prev + α·(x − y_prev) where α = dt / (RC + dt) and RC = 1/(2π·fc). dt is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
Lua node: Receives inputs table (indexed by signal name) and a persistent state table across calls. The os, io, package, and debug libraries are disabled.
3.7 Interface Storage
Interfaces are stored as XML files in a configurable directory on the server. The
<interface> element carries an optional kind attribute (panel default, or plot);
plot panels add a nested <layout> split tree referencing plot widgets by id. Beyond
widgets, an interface may also contain panel-local variables and a <logic> flow graph
(<node> + <param> + <wire>), all round-tripping through the same XML.
Per-panel access rules and folder placement are kept in a sidecar acl.json in the same
directory (not in the interface XML), and prior versions of each panel are retained for the
version history endpoints.
<interface name="My Panel" version="1" created="2026-04-24T12:00:00Z">
<widget id="w1" type="plot" x="100" y="200" w="600" h="300">
<signal ds="epics" name="EPICS:CURRENT" color="#ff0000"/>
<signal ds="epics" name="EPICS:VOLTAGE" color="#0000ff"/>
<option key="plotType" value="timeseries"/>
<option key="yMin" value="auto"/>
<option key="yMax" value="auto"/>
<option key="timeWindow" value="60"/>
<option key="legend" value="bottom"/>
</widget>
<widget id="w2" type="led" x="50" y="50" w="80" h="80">
<signal ds="epics" name="EPICS:STATUS"/>
<option key="condition" value="value > 0"/>
<option key="colorTrue" value="#00ff00"/>
<option key="colorFalse" value="#ff0000"/>
<option key="label" value="OK"/>
</widget>
</interface>
3.8 Panel Logic Engine
Panel logic is a client-side flow engine (web/src/lib/logic.ts, singleton logicEngine).
The graph is built/edited in the panel editor's Logic tab (LogicEditor.tsx) and serialized
into the interface XML. In View mode, Canvas calls logicEngine.load(iface.logic) on
mount and clear() on unmount. The engine subscribes to every referenced signal into a live
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, 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
the root context, independent of any client. The engine subscribes to the broker, fires on
cron schedules and signal alarm/threshold conditions, executes a Lua block for custom logic,
and writes results back to signals. Graphs are persisted by a store and managed via
/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
(ConfigInstance.Resolve), coerces it to float64 and writes it to the node's target;
action.config.write evaluates an expression and stores the (type-coerced, via
coerceParamValue) value into a parameter, creating a new instance revision;
action.config.create creates a new instance for a set, optionally seeding its values from
another instance; and action.config.snapshot reads every target signal's current live value
(via the one-shot Broker.ReadNow, type-coerced by confmgr.Snapshot) and stores them as a
new instance. All mutating nodes are audited. The panel-logic engine
(web/src/lib/logic.ts) mirrors all five client-side via the REST endpoints
(/config/instances/{id}/apply, GET/PUT /config/instances/{id}, POST /config/instances,
POST /config/sets/{id}/snapshot).
Panel-logic apply/read/write nodes additionally support an instanceSource: 'var' mode that
reads the target instance id (a string) from a panel-local variable rather than a fixed id —
fed by the Config Selector widget (web/src/widgets/ConfigSelect.tsx), which lists a
set's instances in a combo and writes the chosen id to that variable. Control-logic nodes use
fixed ids only (its variables are numeric, instance ids are strings). To let the selector
filter instances by set without a GET per instance, confmgr.Store.List now returns each
instance's setId in its Meta.
3.10 Access Control
Identity and global policy live in internal/access (Policy, 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
The configuration manager is internal/confmgr: a two-tier model of sets (typed
parameter schemas binding target signals) and instances (values for a chosen set).
model.go defines ConfigSet/Parameter/ConfigInstance; apply.go validates an
instance against its set (checkValue) and writes each value to its target signal,
returning a per-parameter apply report; diff.go produces the structural per-parameter
diff used by the /config/{sets,instances}/diff endpoints.
store.go persists each object as configs/{sets,instances,rules}/{id}.json, with superseded
revisions backed up as {id}.vN.json alongside — the same git-style scheme as panels and
synthetic/control-logic, so Versions/GetVersion/Promote/Fork behave identically.
Delete is non-destructive: the object and all its backups are moved to a timestamped
trash/configs/… folder.
CUE rules (cue.go, KindRule). A third versioned object type carries a CUE source
(cuelang.org/go) bound to a set via SetID. EvaluateRule compiles the source, unifies it
with the instance values (ctx.Encode), and validates with cue.Concrete(true): regular
fields whose key matches a parameter constrain its value (failures become RuleViolations),
while concrete derivations whose value differs from the input are reported (and persisted) as
transformations; hidden fields _x and definitions #X are helpers, excluded from both.
CreateInstance/UpdateInstance call applyRules after the structural ValidateAgainst:
all rules bound to the set are run in order (evaluateRules), a violation aborts the save as
a *RuleError, and successful transformations are merged back into the stored values (set
parameters only). ValidateInstanceRules is the read-only counterpart behind
/config/instances/{id}/validate; EvaluateRule is exposed directly via
/config/rules/check for the live editor.
3.12 Document Versioning
Versioning is implemented per storage layer but follows one shared contract: the live
revision lives in the primary file; each save backs up the previous revision as
{id}.v{N}.{ext}; VersionMeta{Version,Name,Tag,Current,SavedAt} describes each. Promote
re-saves an older revision on top (creating a new current revision, never destroying
history); Fork writes the revision out under a fresh id with its version reset to 1. The
frontend web/src/VersionHistory.tsx (VersionTree + DiffViewer) consumes these
generically; line diffs are computed client-side in web/src/lib/linediff.ts, while
config sets/instances use the backend structural diff instead. Config rules reuse the
generic client-side DiffViewer (line diff over the source).
4. Frontend Architecture
4.1 WebSocket Client (lib/ws.ts)
- Singleton WebSocket connection, reconnects with exponential back-off.
- Subscription reference counting: multiple widgets subscribing to the same signal result in one server subscription message.
- Incoming updates are dispatched to per-signal stores.
wsClient.history(sig, start, end, maxPoints)returns a Promise resolving to timestamped point arrays.
4.2 Signal Stores (lib/stores.ts)
// One nanostores atom per subscribed signal
const signalStores = new Map<string, SignalStore>();
export function getSignalStore(ref: SignalRef): SignalStore { ... }
export function getMetaStore(ref: SignalRef): MetaStore { ... }
Widgets subscribe to stores directly; store updates trigger re-renders only in the consuming component.
4.3 Edit Mode Canvas (EditCanvas.tsx)
The edit canvas is a free-form HTML div with absolutely positioned widget components:
- Each widget renders as an absolutely positioned
<div>at(x, y)with(w, h)dimensions. - Selection shows a CSS-outlined bounding box with 8 resize handles rendered as small squares.
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from the drop event offset.
- Undo/redo uses an array of past interface snapshots (max depth 50).
- Align/distribute operations compute target positions geometrically and generate a single undo entry.
- Multi-select via Ctrl+click or rubber-band area select.
4.4 Widget Rendering in View Mode (Canvas.tsx)
View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
- Each widget subscribes to its signal store(s) in a
useEffectand re-renders only when values change. - uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser, waveform) manage their own canvas elements inside their widget component. The
waveformplot renders a waveform (array) signal's latest[]float64as an x-vs-index trace, replacing the trace on each update. - Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
4.5 Plot Panels (SplitLayout.tsx, PlotPanelCanvas.tsx)
Plot panels are interfaces of kind === 'plot' whose plots fill the viewport in a recursive
split layout (lib/plotLayout.ts holds the pure tree helpers; SplitLayout.tsx is the
shared presentational renderer with draggable dividers):
- In View mode,
Canvasrenders the saved layout read-only with livePlotWidgets. - In Edit mode,
PlotPanelCanvas.tsxadds per-pane overlays: split (⬌/⬍), close (✕), click to select, and signal-drop to add a signal to that pane's plot. - Each pane reuses the standard
PlotWidget, so all plot sub-types and options apply. - Layout edits (split/close/resize) participate in the editor's undo/redo.
4.6 Resizable Panels
Both edit and view modes support mouse-drag panel resizing:
- View mode: drag handle between the interface list pane and the main content area.
- Edit mode: drag handles on both sides of the central canvas (signal tree ↔ canvas, canvas ↔ properties pane).
- Handle width: 5 px, cursor changes to
ew-resizeon hover. - Minimum panel widths enforced to prevent collapse below usable size.
4.7 HiDPI / Zoom Support
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
remso 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 settingdocument.documentElement.style.fontSizeon 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.devicePixelRatioand sizes canvases accordingly.
4.8 Lua Editor (LuaEditor.tsx)
A syntax-highlighted code editor for Lua scripts in the Synthetic signal wizard:
- Implemented as a
<textarea>overlaid on a<pre>element; the textarea hascolor: transparent; caret-color: #e2e8f0so only the caret is visible — the<pre>provides the coloured text behind it. - Tokeniser handles:
--line comments,"..."/'...'string literals,[[...]]long strings, hex and float numeric literals, and all Lua 5.1 keywords. - Scroll position is synchronised between textarea and pre on every scroll event.
5. Build System
5.1 Backend
# Full build (frontend then backend)
make all
# Backend only (frontend must already be built)
make backend
# All tests
make test
# Single Go test
go test ./internal/broker/... -run TestFanOut
# Go vet
go vet ./...
EPICS libca.a is statically linked via CGO_LDFLAGS in internal/datasource/epics/cgo.go.
5.2 Frontend
The frontend is built by a Go tool in tools/buildfrontend/ that invokes the esbuild Go API:
make frontend
# or equivalently:
go generate ./web/...
No Node.js, npm, or any JS build tool is required on the host. The bundler:
- Reads entry point
web/src/main.tsx. - Resolves
preact,uplot, andechartsimports fromweb/vendor/. - Outputs
web/dist/main.jsandweb/dist/main.css. - Copies
web/dist/index.html, vendor CSS, and other static assets.
5.3 Combined Build
.PHONY: all frontend backend clean
all: frontend backend
frontend:
go run ./tools/buildfrontend
backend:
CGO_ENABLED=1 go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
test:
go test ./...
clean:
rm -rf dist/ web/dist/
The backend's //go:embed dist directive in web/embed.go picks up the built frontend automatically. main.go does fs.Sub(web.FS, "dist") to serve a clean root.
6. Configuration
Server is configured via a TOML file (default: uopi.toml, overridable via --config flag):
[server]
listen = ":8080"
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)
# 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
# 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
ca_addr_list = "" # EPICS_CA_ADDR_LIST override
archive_url = "" # EPICS Archive Appliance URL
channel_finder_url = "" # EPICS Channel Finder URL
[datasource.synthetic]
enabled = true
All settings can also be overridden with UOPI_* environment variables (e.g.
UOPI_SERVER_LISTEN, UOPI_EPICS_CA_ADDR_LIST).
7. Testing Strategy
| Layer | Approach |
|---|---|
| Broker | Unit tests with mock data source; verify fan-out, subscribe/unsubscribe lifecycle |
| Synthetic DSP | Table-driven unit tests against known signal inputs/outputs |
| Low-pass filter | Unit tests: step response, frequency attenuation vs. analytical expectation |
| Lua sandbox | Unit tests for sandbox isolation and API surface |
| REST API | httptest integration tests |
| WebSocket protocol | Integration tests with a test client |
| EPICS data source | Integration tests against a local SoftIOC (optional, CI-gated) |
8. Security Considerations
- Lua sandbox: disable
os,io,package,debuglibraries; restrictmathandstringto safe subsets. - WebSocket write operations: validate that the target signal is writable before forwarding to the data source.
- 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. 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.
9. Non-goals (v1)
- Built-in user authentication (a login page / credential store) — identity is delegated to the front-end reverse proxy; authorisation (levels, ACLs, logic allowlist) is implemented.
- TLS termination (expected to be handled by SSH tunnel or a reverse proxy).
- Windows or macOS server binary.
- Mobile-optimised frontend layout.
- Remote plugin loading (plugins compiled in at build time only).