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: 'view', label: 'View Mode' }, { id: 'edit', label: 'Edit Mode' }, { id: 'widgets', label: 'Widgets' }, { id: 'signals', label: 'Signals' }, { id: 'history', label: 'Historical Data' }, { id: 'shortcuts', label: 'Keyboard Shortcuts' }, ]; 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. 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.

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 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 open the wizard. Choose an input signal, a processing node (gain, moving average, lowpass filter, …), and optional metadata. The new signal appears in the tree immediately.

); } 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 = "./data"{'\n\n'}[datasource.epics]{'\n'}enabled     = true{'\n'}archive_url = "http://archiver:17665"{'\n\n'}[datasource.synthetic]{'\n'}enabled = true
); } const SECTION_COMPONENTS: Record JSX.Element> = { start: SectionStart, view: SectionView, edit: SectionEdit, widgets: SectionWidgets, signals: SectionSignals, history: SectionHistory, shortcuts: SectionShortcuts, }; // ── Main modal ──────────────────────────────────────────────────────────────── export default function HelpModal({ initialSection = 'start', onClose }: Props) { const [active, setActive] = useState(initialSection); const Content = 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 (
); }