import { h } from 'preact'; import { useState } from 'preact/hooks'; interface Props { initialSection?: string; onClose: () => void; } // ── SVG Diagrams ───────────────────────────────────────────────────────────── function DiagramViewMode() { return ( {/* outer bg */} {/* toolbar */} uopi Control Room {/* live chip */} live Edit {/* interface list panel */} INTERFACES Control Room Vacuum System RF Status {/* canvas area */} {/* gauge widget */} Temperature 67°C {/* LED */} Beam ON {/* text view */} 1.23 mA {/* bar */} Pressure 65 % {/* plot */} Beam Current {/* Annotations */} {/* arrow to interface list */} ① Interface list {/* arrow to canvas */} ② Live canvas ); } function DiagramEditMode() { return ( {/* toolbar */} uopi Edit My Panel {/* toolbar buttons */} Save ✕ Close {/* signal tree */} SIGNALS Filter signals… ▾ epics CURRENT PRESSURE ▾ stub sine_1hz ramp_10s {/* canvas */} {/* grid dots */} {[0,1,2,3,4,5,6,7,8].map(col => [0,1,2,3,4,5,6].map(row => ( )) )} {/* selected widget with handles */} 42.3°C Temperature {/* resize handles */} {/* another widget */} {/* properties pane */} PROPERTIES Signal TEMPERATURE X 185 Y 75 Width 120 Height 60 {/* Annotations */} ① Signal tree ② Canvas ③ Properties ); } function DiagramDragDrop() { return ( {/* Step 1 */} ① SIGNAL TREE ▾ epics TEMPERATURE PRESSURE CURRENT {/* drag cursor hint */} drag signal → {/* Arrow 1→2 */} {/* Step 2 */} ② DROP ON CANVAS {/* ghost drag indicator */} TEMPERATURE drop here {/* Arrow 2→3 */} {/* Step 3: widget picker */} ③ PICK WIDGET TYPE Gauge Bar (horizontal) Text View Plot LED ); } function DiagramWidgets() { return ( {/* Text View */} 42.7 °C Text View {/* Gauge */} 67% Gauge {/* Bar H */} Bar (horiz.) 65 % {/* LED */} Beam ON LED {/* Plot */} Time-series Plot {/* Set Value */} Set-point Control 100.0 Set {/* Button */} Button RESET ); } function DiagramHistory() { return ( {/* toolbar */} {/* Live button (inactive) */} Live {/* start input */} 2026-01-15 09:00 {/* end input */} 2026-01-15 10:00 {/* Load button */} Load {/* Plot showing historical data */} Loaded 3 600 historical points · 09:00 → 10:00 {/* annotations */} ① Live ② Start time ③ End time ④ Load ); } function DiagramSignalTree() { return ( {/* header */} Signals {/* search */} Filter signals… 🔍 CF {/* toolbar */} + Synthetic CSV {/* epics group */} ▾ epics + TEMPERATURE PRESSURE BEAM_CURRENT {/* stub group */} ▾ stub sine_1hz ramp_10s my_custom_pv {/* label custom */} custom ); } // ── Section content ─────────────────────────────────────────────────────────── const SECTIONS = [ { id: 'start', label: 'Getting Started' }, { id: 'tips', label: 'Quick Tips' }, { id: 'view', label: 'View Mode' }, { id: 'edit', label: 'Edit Mode' }, { id: 'widgets', label: 'Widgets' }, { id: 'plots', label: 'Plot Panels' }, { id: 'signals', label: 'Signals' }, { id: 'logic', label: 'Panel Logic' }, { id: 'control', label: 'Control Logic' }, { id: 'sharing', label: 'Sharing & Access' }, { id: 'audit', label: 'Audit Log' }, { id: 'history', label: 'Historical Data' }, { id: 'shortcuts', label: 'Keyboard Shortcuts' }, ]; // Quick tips, grouped by the mode they apply to. Each links to the manual // section that covers it in depth. (Previously surfaced via a floating // contextual-help popover; now consolidated here in the manual.) const TIPS: Record<'view' | 'edit', Array<{ text: string; section: string }>> = { view: [ { text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' }, { text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' }, { text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' }, { text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' }, { text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' }, { text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' }, { text: 'The 🛡 Audit button (for permitted users) opens the log of system-affecting actions.', section: 'audit' }, { text: 'The status chip turns green and shows your username when the WebSocket is connected.', section: 'view' }, ], edit: [ { text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' }, { text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' }, { text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' }, { text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' }, { text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' }, { text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' }, { text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' }, { text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' }, ], }; function SectionTips({ onNavigate }: { onNavigate: (section: string) => void }) { const groups: Array<['view' | 'edit', string]> = [ ['view', 'In View mode'], ['edit', 'In Edit mode'], ]; return (

A handful of quick pointers to get the most out of uopi. Click any tip to jump to the section that explains it in detail.

{groups.map(([mode, title]) => (

{title}

))}
); } function SectionStart() { return (

uopi is a web-based HMI for monitoring and controlling EPICS-based scientific and industrial systems. It runs as a single binary — open a browser, no installation required.

How it works

  1. The server connects to your control system (EPICS, synthetic signals).
  2. Open http://<host>:8080 in any browser.
  3. Pick an HMI panel from the left sidebar to view live data.
  4. Switch to Edit mode to build or modify panels.

Two modes

👁 View Mode

Monitor live signals in read-only HMI panels. Right-click any widget for signal info or to export data as CSV.

✏ Edit Mode

Drag signals from the tree onto the canvas, resize and configure widgets, then save your panel to the server.

Quick navigation

); } function SectionView() { return (

View mode displays a live HMI panel. Widget values update in real time via WebSocket.

View mode layout: ① interface list · ② live canvas · WebSocket status top-right.

Interface list

Context menu

Right-click any widget to:

Connection status

The coloured chip in the top-right shows the WebSocket connection state. When connected it reads "connected as <your username>", so you can confirm the identity your actions are attributed to in the audit log. A red "disconnected" banner appears at the top while reconnecting — live values will resume automatically.

); } function SectionEdit() { return (

Edit mode gives you a drag-and-drop canvas, a signal tree on the left, and a properties pane on the right.

Edit mode layout: ① signal tree · ② canvas with snap grid · ③ properties pane.

Adding widgets

Drag a signal from the tree → drop on canvas → choose a widget type.

Selecting and moving

  • Click a widget to select it — resize handles appear at the corners and edges.
  • Drag the widget body to move it; drag a handle to resize.
  • Ctrl+click to add/remove widgets from the selection.
  • Drag on empty canvas to rubber-band select multiple widgets.
  • Arrow keys nudge the selection by 1 px (or grid size with Shift).

Align & distribute

When 2+ widgets are selected the alignment toolbar appears. Buttons align edges or centres; with 3+ selected you can distribute spacing evenly.

Layout & Logic tabs

The centre of the editor has two tabs:

  • Layout — the drag-and-drop widget canvas described above.
  • Logic — a visual flow editor that adds interactive behaviour to the panel (buttons that run actions, thresholds that pop up dialogs, timers, …). See the Panel Logic section. The tab is hidden if you are not permitted to edit logic.

Local variables

Panels can define their own local variables — lightweight values that live only inside the panel (no data source needed). Add them from the signal tree's Local group (or the Logic palette). They are written by Set-value widgets, buttons and logic actions, and referenced anywhere a signal is, making them handy for set-points, toggles and counters used by panel logic.

Saving

  • Save — stores the panel on the server (XML). A indicates unsaved changes.
  • Export — downloads the XML file locally.
  • Import — loads an XML file from disk (replaces the current canvas).
); } function SectionPlots() { return (

A plot panel is a special interface dedicated to charts. Instead of free-form boxes, plots fill the viewport and you split the space between them like panes in a tiling window manager (tmux / IDE style).

Creating one

Splitting & arranging

In view mode the saved split layout fills the screen with live, streaming plots.

); } function SectionLogic() { return (

The Logic tab in the panel editor is a node-graph (Node-RED style) flow editor. You wire trigger nodes to action nodes to give a panel interactive behaviour. Logic runs entirely client-side while the panel is open in view mode.

Building a flow

Node types

{[ { type: 'Triggers', desc: 'Button press, threshold crossing, value change, timer/interval, panel loop, and On-open / On-close lifecycle.' }, { type: 'Logic', desc: 'AND gate, If (then/else branches), and Loop (count or while).' }, { type: 'Actions', desc: 'Write a value to a signal/variable, Delay, Log (debug), Array push/set/remove/pop/clear on an array local variable, and Export-CSV.' }, { type: 'Dialogs', desc: 'Info and Error pop-ups, and a Set-point prompt that asks the user for a number and writes it to a target.' }, ].map(w => (
{w.type} {w.desc}
))}

System helpers

); } function SectionControl() { return (

Control logic is server-side automation. Unlike panel logic (which only runs while a panel is open in a browser), control-logic graphs run continuously on the server, independent of any client. Open them with the ⚙ Control logic button in the view-mode toolbar.

What it can do

Editing both panel logic and control logic can be restricted to an allowlist of users or groups (server.logic_editors in the config). When restricted, users not on the list keep full access to everything else but cannot add or change logic — the Logic tab and the ⚙ Control logic button are hidden for them.

); } function SectionSharing() { return (

uopi has graduated, server-enforced access control. Identity comes from a trusted reverse-proxy header (with a configurable default for unproxied/LAN use) — there is no login page in the app itself.

Global access levels

Panel ownership & sharing

Folders

); } function SectionAudit() { return (

When the server is configured with [audit] enabled = true, every system-affecting action is appended to an append-only log so it can be reviewed later. Permitted users open it with the 🛡 Audit button in the view-mode toolbar.

What gets recorded

Each entry records the time, the actor (username, or the graph name for automated actions), the action, the affected data source / signal and value, the client address, and whether it succeeded.

Viewing the log

Access to the audit log is gated by an allowlist (audit.readers in the config), following the same model as logic editing: an empty list means every user may view it, otherwise only the listed users or groups can. The 🛡 Audit button is hidden for users who are not permitted.

); } function SectionWidgets() { return (

Each widget is configured in the Properties pane on the right. The available options depend on the widget type.

Widget type overview — drag a signal from the tree to place one.

{[ { type: 'Text View', desc: 'Shows the current numeric or string value. Supports alarm colouring.' }, { type: 'Gauge', desc: 'Circular arc gauge with configurable range and colour zones.' }, { type: 'Bar (H / V)', desc: 'Horizontal or vertical filled bar, great for percentages or levels.' }, { type: 'LED', desc: 'Coloured indicator. Turns green/red based on a threshold or enum value.' }, { type: 'Multi-LED', desc: 'Bitset indicator — shows individual bits of an integer signal as LEDs.' }, { type: 'Set Value', desc: 'Text input that writes a value back to a writable signal on Enter or click.' }, { type: 'Button', desc: 'Sends a fixed value to a signal when clicked.' }, { type: 'Plot', desc: 'Multi-signal chart. Supports time-series (uPlot), FFT, waterfall, histogram, bar chart, logic analyser.' }, { type: 'Text Label', desc: 'Static annotation text — no signal required.' }, { type: 'Image', desc: 'Static image loaded from a URL.' }, { type: 'Link', desc: 'Navigates to another interface when clicked.' }, ].map(w => (
{w.type} {w.desc}
))}

Plot widget details

); } function SectionSignals() { return (

The signal tree (left pane in Edit mode) lets you browse, search, and manage the signals available from each data source.

Finding signals

  • Type in the Filter signals… box to search by name or description.
  • Click a group header to collapse or expand that data source.
  • Drag any signal onto the canvas to create a widget.

Adding custom signals

  • Click + next to a group name to type a PV name manually.
  • Custom signals are saved in your browser (localStorage) and persist across reloads.
  • Click on a custom signal to remove it.

CSV import

Click CSV to import a list of signals from a file. Each line should be either:

  • PV_NAME — added to the epics data source.
  • ds,PV_NAME — added to the specified data source.

Lines starting with # are treated as comments.

Channel Finder

If your server is configured with an EPICS Channel Finder URL, a 🔍 button appears when you type in the filter box. Click it to search Channel Finder and import matching PV names.

Synthetic signals

Click + Synthetic to compose a new signal from existing ones. A quick wizard covers the common case (pick an input + a processing node such as gain, offset, moving average, lowpass/highpass, derivative, integral, clamp or a custom formula). For multi-input pipelines, the node-graph editor lets you wire inputs through a chain of DSP blocks visually. The new signal appears in the tree immediately.

Each synthetic signal has a visibility scope: panel (only the panel that created it), user, or global (shared with everyone).

Local variables

The Local group holds panel-local variables — values stored in the panel itself with no data source. Use them for set-points, toggles and counters driven by panel logic. They are added in the editor and saved with the panel.

); } function SectionHistory() { return (

Historical data is retrieved from EPICS Archive Appliance when the server is configured with an archive_url. Only the Plot widget in time-series mode supports historical ranges.

Time navigation toolbar: ① Live button · ② start · ③ end datetime · ④ Load.

How to load historical data

  1. Set the start and end date/time fields in the toolbar.
  2. Click Load — all plot widgets on the current panel will fetch the requested range.
  3. While loading, a "Loading history…" overlay appears on each plot.
  4. If no data is available, the overlay shows "No archive data for this range".
  5. Click ● Live (highlighted when active) to return to streaming mode.

Notes

); } function SectionShortcuts() { const shortcuts: Array<[string, string]> = [ ['Ctrl+C', 'Copy selected widgets (Edit mode)'], ['Ctrl+V', 'Paste widgets from clipboard (Edit mode)'], ['Ctrl+Z', 'Undo last change (Edit mode)'], ['Ctrl+Y / Ctrl+Shift+Z', 'Redo (Edit mode)'], ['Ctrl+A', 'Select all widgets (Edit mode)'], ['Delete / Backspace', 'Delete selected widgets (Edit mode)'], ['Arrow keys', 'Nudge selection by 1 px (Edit mode)'], ['Shift+Arrow', 'Nudge by grid size (Edit mode)'], ['Ctrl+click', 'Add/remove widget from multi-selection'], ['Escape', 'Close dialogs / cancel input'], ['?', 'Open this help'], ]; return (

All shortcuts that work while the canvas or modal has focus.

{shortcuts.map(([key, action]) => ( ))}
ShortcutAction
{key} {action}

API & Metrics

Configuration file

[server]{'\n'}listen      = ":8080"{'\n'}storage_dir = "./interfaces"{'\n'}# optional: restrict who may edit panel/control logic{'\n'}# logic_editors = ["operators"]{'\n\n'}[datasource.epics]{'\n'}enabled     = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true
); } const SECTION_COMPONENTS: Record void }) => JSX.Element> = { start: SectionStart, tips: SectionTips, view: SectionView, edit: SectionEdit, widgets: SectionWidgets, plots: SectionPlots, signals: SectionSignals, logic: SectionLogic, control: SectionControl, sharing: SectionSharing, audit: SectionAudit, history: SectionHistory, shortcuts: SectionShortcuts, }; // ── Main modal ──────────────────────────────────────────────────────────────── export default function HelpModal({ initialSection = 'start', onClose }: Props) { const [active, setActive] = useState(initialSection); const Content: (props: { onNavigate: (section: string) => void }) => JSX.Element = SECTION_COMPONENTS[active] ?? SectionStart; // Close on backdrop click function handleBackdrop(e: MouseEvent) { if ((e.target as Element).classList.contains('help-backdrop')) onClose(); } // Close on Escape function handleKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); } return (
); }