From b76b7f0ba8610679e19b4a1cf8e16f88789d2d7f Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 26 Apr 2026 14:28:48 +0200 Subject: [PATCH] complete first iteration --- web/src/ContextualHelp.tsx | 91 +++++ web/src/HelpModal.tsx | 748 +++++++++++++++++++++++++++++++++++++ 2 files changed, 839 insertions(+) create mode 100644 web/src/ContextualHelp.tsx create mode 100644 web/src/HelpModal.tsx diff --git a/web/src/ContextualHelp.tsx b/web/src/ContextualHelp.tsx new file mode 100644 index 0000000..a4c3b37 --- /dev/null +++ b/web/src/ContextualHelp.tsx @@ -0,0 +1,91 @@ +import { h } from 'preact'; +import { useState, useRef, useEffect } from 'preact/hooks'; + +interface Props { + mode: 'view' | 'edit'; + onOpenManual: (section?: string) => void; +} + +const TIPS: Record> = { + 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: 'The dot in the status chip turns green 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: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' }, + ], +}; + +export default function ContextualHelp({ mode, onOpenManual }: Props) { + const [open, setOpen] = useState(false); + const [tipIndex, setTipIndex] = useState(0); + const ref = useRef(null); + const tips = TIPS[mode]; + + // Close on outside click + useEffect(() => { + if (!open) return; + function handler(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + // Cycle to a new random tip each time the popover opens + function handleOpen() { + setTipIndex(t => (t + 1) % tips.length); + setOpen(o => !o); + } + + const tip = tips[tipIndex]; + + return ( +
+ + + {open && ( + + )} +
+ ); +} diff --git a/web/src/HelpModal.tsx b/web/src/HelpModal.tsx new file mode 100644 index 0000000..65d0cff --- /dev/null +++ b/web/src/HelpModal.tsx @@ -0,0 +1,748 @@ +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. +
  3. Open http://<host>:8080 in any browser.
  4. +
  5. Pick an HMI panel from the left sidebar to view live data.
  6. +
  7. Switch to Edit mode to build or modify panels.
  8. +
+ +

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

+
    +
  • Click an interface in the left list to open it in view mode.
  • +
  • Click Edit (top-right) to switch to the editor.
  • +
  • Click βœ• Close in Edit mode to return to View mode.
  • +
  • Press ? anywhere to open this help.
  • +
+
+ ); +} + +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

+
    +
  • Click any panel name to load it on the canvas.
  • +
  • Use the action buttons (edit ✎, clone ⧉, delete βœ•) that appear on hover.
  • +
  • Collapse the list by clicking the β—€ button at the top.
  • +
+ +

Context menu

+

Right-click any widget to:

+
    +
  • View the signal name and current value.
  • +
  • Copy the signal name to the clipboard.
  • +
  • Export the last 1 000 samples as a CSV file.
  • +
+ +

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

+
    +
  • Add multiple signals via the Signals section in the Properties pane.
  • +
  • Choose plot type: timeseries, FFT, waterfall, histogram, bar, or logic.
  • +
  • Set Time window to control how many seconds of live data are shown.
  • +
  • In Historical mode the entire requested range is loaded at once.
  • +
+
+ ); +} + +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. +
  3. Click Load β€” all plot widgets on the current panel will fetch the requested range.
  4. +
  5. While loading, a "Loading history…" overlay appears on each plot.
  6. +
  7. If no data is available, the overlay shows "No archive data for this range".
  8. +
  9. Click ● Live (highlighted when active) to return to streaming mode.
  10. +
+ +

Notes

+
    +
  • Historical data is requested over the WebSocket connection (not REST).
  • +
  • Requests time out after 15 seconds if the archive does not respond.
  • +
  • Non-timeseries plot types (FFT, waterfall, etc.) remain in live mode regardless of the time range.
  • +
  • Point-value widgets (gauge, bar, LED, …) are unaffected by the time range selection.
  • +
+
+ ); +} + +function SectionShortcuts() { + const shortcuts: Array<[string, string]> = [ + ['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

+
    +
  • REST API: /api/v1/datasources, /api/v1/signals, /api/v1/interfaces, …
  • +
  • Prometheus metrics: /metrics
  • +
  • Health check: /healthz
  • +
  • WebSocket: ws://<host>/ws
  • +
+ +

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 ( +
+ +
+ ); +}