Initial commit
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**uopi** is a web-based HMI (Human-Machine Interface) for monitoring and controlling industrial/scientific systems, primarily EPICS-based. It consists of:
|
||||
|
||||
- A **backend** — a single portable binary (targeting old Linux machines) that serves a web UI and REST API, connects to data sources (EPICS, synthetic), and multiplexes live data across multiple clients via WebSocket.
|
||||
- A **frontend** — a fast, reactive web UI (no WebGPU) with two modes: **edit** (drag-and-drop interface builder) and **view** (live interaction with HMI panels).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
- Single self-contained binary with embedded frontend assets.
|
||||
- Data sources are plugin-extensible; built-in: **EPICS** and **Synthetic**.
|
||||
- Data ingestion uses subscribe/monitor patterns where available; timestamps from the source are authoritative.
|
||||
- The same channel data is shared (mutualized) across all connected clients requesting the same signal.
|
||||
- Interfaces are stored server-side and serialized as **XML**.
|
||||
- REST API for headless/desktop client access alongside the WebSocket push for the web frontend.
|
||||
|
||||
### Data Sources
|
||||
|
||||
**EPICS**: Use Channel Access or PVAccess. Retrieve all metadata from PV name (type, range, enum values, R/W mode, units). Prefer monitors over polling. Support historical data via EPICS Archive Appliance when available.
|
||||
|
||||
**Synthetic**: Compose signals from existing sources (any DS) and math/DSP functions (moving average, bandpass, gain, offset, derivative, custom formula). Support Lua scripting for custom logic.
|
||||
|
||||
### Frontend
|
||||
|
||||
Two primary modes toggled via toolbar:
|
||||
|
||||
- **Edit mode**: resizable signal tree pane (left) + free-form canvas (center) + properties pane (right). Signals are drag-and-dropped from the tree onto the canvas; on drop, compatible widget types are shown. Widget selection shows bounding box with resize handles. Multi-select via Ctrl+click or area select enables group move, delete, and align/distribute. Undo/redo (Ctrl+Z / Ctrl+Shift+Z). Save/load to server; export/import as local XML file.
|
||||
- **View mode**: collapsible interface list pane (left) + live HMI canvas. Right-click context menu per widget: signal info, copy signal name, export data to CSV. Optional historical time navigation + "live" button.
|
||||
|
||||
### Widget Types
|
||||
|
||||
- Text view, gauge, vertical/horizontal bar, set-value control, LED (with condition), multi-LED (bitset), button (command/set value).
|
||||
- Plot widget (multi-signal): timeseries, FFT, waterfall (multidimensional), histogram, bar chart, logic analyzer.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend:** Go 1.22+, single binary, `//go:embed` for frontend assets
|
||||
- **Frontend:** Svelte 5 + TypeScript + Vite; plots via uPlot (time-series) and ECharts (others); edit canvas via Konva
|
||||
- **Config:** TOML file + `UOPI_*` env var overrides (`github.com/BurntSushi/toml`)
|
||||
- **WebSocket:** `nhooyr.io/websocket` (no CGo)
|
||||
- **EPICS:** CGo bindings to `libca`; `gopher-lua` for synthetic signal Lua scripts
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
cmd/uopi/ # main — wires config, embed, server
|
||||
internal/config/ # TOML config loading
|
||||
internal/server/ # HTTP + WebSocket handlers
|
||||
internal/broker/ # signal fan-out (Phase 1)
|
||||
internal/datasource/# DataSource interface + EPICS + synthetic (Phases 2–3)
|
||||
internal/dsp/ # DSP functions for synthetic (Phase 3)
|
||||
internal/storage/ # interface XML persistence (Phase 6)
|
||||
internal/api/ # REST handlers (Phase 1+)
|
||||
web/ # Svelte source (npm); web/embed.go provides embed.FS to Go
|
||||
web/dist/ # built frontend — generated, not committed
|
||||
dist/ # compiled binary — generated, not committed
|
||||
```
|
||||
|
||||
The embed package lives at `web/embed.go` (not in `cmd/`) because `//go:embed` paths cannot use `..`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Full build (frontend then backend)
|
||||
make all
|
||||
|
||||
# Backend only (frontend must already be built)
|
||||
make backend
|
||||
|
||||
# Frontend only
|
||||
make frontend
|
||||
|
||||
# Run backend (dev mode, frontend must be running separately)
|
||||
go run ./cmd/uopi
|
||||
|
||||
# Frontend dev server (proxies /api and /healthz to :8080)
|
||||
cd web && npm run dev
|
||||
|
||||
# All tests
|
||||
make test
|
||||
|
||||
# Single Go test
|
||||
go test ./internal/broker/... -run TestFanOut
|
||||
|
||||
# Go vet
|
||||
go vet ./...
|
||||
|
||||
# Svelte type check
|
||||
cd web && npm run check
|
||||
|
||||
# Svelte lint
|
||||
cd web && npm run lint
|
||||
```
|
||||
|
||||
## Key Architectural Notes
|
||||
|
||||
- `web/embed.go` declares `//go:embed dist` and exports `var FS embed.FS`; `main.go` does `fs.Sub(web.FS, "dist")` to get a clean root FS for `http.FileServerFS`.
|
||||
- The `DataSource` interface (to be defined in `internal/datasource/iface.go`) is the extension point for new data sources.
|
||||
- The signal broker (Phase 1) is the central fan-out: one upstream goroutine per signal, N client channels downstream. Reference-counted subscribe/unsubscribe.
|
||||
- WebSocket messages are JSON; protocol defined in `docs/TECHNICAL_SPEC.md §3.3`.
|
||||
@@ -0,0 +1,31 @@
|
||||
.PHONY: all frontend backend test clean
|
||||
|
||||
all: frontend backend
|
||||
|
||||
frontend:
|
||||
cd web && npm ci && npm run build
|
||||
|
||||
backend:
|
||||
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
|
||||
# Build backend without -s -w for debugging
|
||||
backend-debug:
|
||||
go build -o dist/uopi ./cmd/uopi
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
cd web && npm run check
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
cd web && npm run lint
|
||||
|
||||
clean:
|
||||
rm -rf dist/ web/dist/
|
||||
|
||||
# Run dev servers (requires two terminals or a process manager)
|
||||
run-backend:
|
||||
go run ./cmd/uopi
|
||||
|
||||
run-frontend:
|
||||
cd web && npm run dev
|
||||
@@ -0,0 +1,121 @@
|
||||
# uopi
|
||||
|
||||
A web-based HMI (Human-Machine Interface) for monitoring and controlling EPICS-based scientific and industrial control systems.
|
||||
|
||||
uopi runs as a single portable binary. Access it from any browser — including over an SSH tunnel — with no client-side installation required.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Live data** from EPICS (Channel Access / PVAccess) and user-defined synthetic signals
|
||||
- **Web-based HMI editor** — drag-and-drop widgets, resize, align, undo/redo — saved as XML
|
||||
- **Rich widget library** — gauges, LEDs, bars, plots (time series, FFT, waterfall, histogram), controls
|
||||
- **Multiple concurrent clients** sharing the same data subscriptions
|
||||
- **Historical data** navigation via EPICS Archive Appliance integration
|
||||
- **Synthetic signals** — compose, filter, and transform existing signals with a DSP pipeline or Lua scripts
|
||||
- **Single binary** with embedded frontend; no runtime dependencies
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- EPICS Base installed (for the EPICS data source; `libca` must be linkable)
|
||||
- Go 1.22+
|
||||
- Node.js 20+ and npm
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
make all
|
||||
```
|
||||
|
||||
The binary is written to `dist/uopi`.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
./dist/uopi --config uopi.toml
|
||||
```
|
||||
|
||||
Then open `http://localhost:8080` in your browser.
|
||||
|
||||
**SSH tunnel example:**
|
||||
|
||||
```bash
|
||||
ssh -L 8080:localhost:8080 user@controlhost
|
||||
# then open http://localhost:8080 locally
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Create `uopi.toml` (or copy and edit the example):
|
||||
|
||||
```toml
|
||||
[server]
|
||||
listen = ":8080"
|
||||
storage_dir = "./interfaces" # where interface XML files are stored
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if non-empty
|
||||
archive_url = "" # EPICS Archive Appliance base URL
|
||||
|
||||
[datasource.synthetic]
|
||||
enabled = true
|
||||
definitions_file = "./synthetic.json"
|
||||
```
|
||||
|
||||
All settings can be overridden with environment variables using the prefix `UOPI_` and double underscores for nesting, e.g. `UOPI_SERVER_LISTEN=":9090"`.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
See [`docs/TECHNICAL_SPEC.md`](docs/TECHNICAL_SPEC.md) for full architecture details.
|
||||
|
||||
```bash
|
||||
# Backend only (with stub data source)
|
||||
go run ./cmd/uopi
|
||||
|
||||
# Frontend dev server (proxies /api to backend at :8080)
|
||||
cd web && npm install && npm run dev
|
||||
|
||||
# Run backend tests
|
||||
go test ./...
|
||||
|
||||
# Run a single backend test
|
||||
go test ./internal/broker/... -run TestFanOut
|
||||
|
||||
# Frontend type-check
|
||||
cd web && npm run check
|
||||
|
||||
# Frontend lint
|
||||
cd web && npm run lint
|
||||
|
||||
# Full build (frontend + backend)
|
||||
make all
|
||||
|
||||
# Run all tests
|
||||
make test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docs
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [`docs/FUNCTIONAL_SPEC.md`](docs/FUNCTIONAL_SPEC.md) | User-facing feature specification |
|
||||
| [`docs/TECHNICAL_SPEC.md`](docs/TECHNICAL_SPEC.md) | Technology choices, architecture, API design |
|
||||
| [`docs/WORK_PLAN.md`](docs/WORK_PLAN.md) | Phased development plan with milestones |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
TBD
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
# Requirements
|
||||
|
||||
## Beck-end
|
||||
|
||||
- Portable single executable capable of running on old Linux machine
|
||||
- It should serve a web UI frontend that could be easily forwarded via SSH tunnel
|
||||
- It should also have a rest API for desktop client if needed
|
||||
- It should support multiple clients
|
||||
|
||||
### Data source
|
||||
|
||||
- primary data-source are EPICS and Synthetic, but the service should be easily extendible (via plugins)
|
||||
- data-source should get data at maximum rate possible and mutualize the data with all clients that
|
||||
require the same data
|
||||
- timestamp from the data-source should be used when available
|
||||
- subscribe mechanism should be preferred when possible
|
||||
- user can mix live data and archived data when available
|
||||
|
||||
#### EPICS Data-Source
|
||||
|
||||
- should get all information available from signal name (type, ranges, enum values [if any], acquisition mode, write/read mode etc.)
|
||||
- subscription/monitor preferable
|
||||
- optimize multiple signal access when possible
|
||||
- if possible retrieve list of available signals
|
||||
- if possible use EPICS archive for access to historical data
|
||||
|
||||
#### Synthetic Data-Source
|
||||
|
||||
- use any existing signal (from this datasource of other) or synthetic sources (random signals, timers etc) and process it using any combination of user defined processing functions (band pass filters, moving average, gain, offset, derivative, custom formula etc)
|
||||
- user should have access to a library of optimized math and signal processing functions
|
||||
- user can use LUA or similar for defining custom code and combine functions or using more advanced logic
|
||||
|
||||
### Front-end
|
||||
|
||||
- Front-end should adapt to screen DPI
|
||||
- It should use modern web tools/libs but no webgpu
|
||||
- It should be as fast as possible and as reactive as possible: it should feel as much as possible like a native program
|
||||
- Front-end has to primary modes: edit and view
|
||||
|
||||
#### Edit mode
|
||||
|
||||
- in this mode user can create monitor and control user interfaces
|
||||
- in a resizable and collapsable left pane a tree of available signals should be visualised:
|
||||
- the list is a combination of the server signal list per datasource
|
||||
- user defined one (e.g. user can add a custom PV name to the epics datasource or new synthetic signal for the synthetic datasource)
|
||||
- user can load a list (csv) of custom signals (NAME, DataSource, DS PARAMETERS)
|
||||
- user can drag and drop signals from the left panel to the main pane:
|
||||
- when signal is dropped a list of possible widgets (iconized) compatible with the signal type is proposed:
|
||||
- text view (just `name: value + unit`)
|
||||
- fft
|
||||
- waterfall plot (for multidimensional signal)
|
||||
- histogram
|
||||
- bar
|
||||
- logic analyser
|
||||
|
||||
- on the main pane user can select widgets:
|
||||
- selected widget should have a rect bounding box with handles and delete button
|
||||
- can move them (drag and drop)
|
||||
- edit content (double click)
|
||||
- gauge
|
||||
- vertical/horizontal bar
|
||||
- set value (`name: new_value | current_value+unit |`set button`)
|
||||
- led (with condition and color and optional name)
|
||||
- button to send command / set value
|
||||
- multi led for bitset (e.g. uint8 each bit is a signal)
|
||||
- plot (can accomodate multiple signals):
|
||||
- timeseries
|
||||
- fft
|
||||
- waterfall plot (for multidimensional signal)
|
||||
- histogram
|
||||
- bar
|
||||
- logic analyser
|
||||
- on the main pane user can select widgets:
|
||||
- selected widget should have a rect bounding box with handles and delete button
|
||||
- can move them (drag and drop)
|
||||
- edit content (double click)
|
||||
- delete (del button, or clicking on a x button on the top right corner)
|
||||
- resize them (using handles)
|
||||
- user can select multiple widgets by ctrl+click (add/remove widget to selection) or area select, when multiple widgets are selected:
|
||||
- user can move them all togheter by dragging any of the selected widget
|
||||
- user can delete all of them (del key)
|
||||
- an align/distribute toolbar should appear letting:
|
||||
- align vertically/horizontally: different option (left/center/right)
|
||||
- distribute evenly vertically/horizontally: different options: distance center or gap size
|
||||
- on the right a widget setting pane should appear when a widget is selected with widget options:
|
||||
- scale or ranges
|
||||
- conditions for led and multiled
|
||||
- plot type and plot settings
|
||||
- labels
|
||||
- font size
|
||||
- text color
|
||||
- and all other usefull options
|
||||
- a top toolbar should have at least the following tools:
|
||||
- show/hide signal pane
|
||||
- show/hide widget pane
|
||||
- undo/redo button (also ctrl+z/ctrl+shift+z)
|
||||
- save/load interface (to server)
|
||||
- export/import (to local file)
|
||||
- add text to the interface
|
||||
- add image to the interface
|
||||
- add links to other interfaces (button that open another interface)
|
||||
- interface are saved and exported as XML
|
||||
|
||||
#### View mode
|
||||
|
||||
- view mode is the default mode
|
||||
- in this mode user has on the left a collapsable interface selection pane:
|
||||
- contains all server saved interface
|
||||
- right click on a existing interface user can edit or clone and edit the interface
|
||||
- it has an import option to load a local interface
|
||||
- it has a new interface button that open the edit mode
|
||||
- when an interface is selected user can interact with the hmi:
|
||||
- edit values
|
||||
- click buttons
|
||||
- zoom/interact with plots
|
||||
- etc
|
||||
- right clicking to a widget open a menu with:
|
||||
- signal info : open a small window/dialog with signal or signals information (DS, type, unit, etc)
|
||||
- copy signal name: copy signal name to system clipboard
|
||||
- export data to csv: export available data of the current signal
|
||||
- any other information/action that could be useful
|
||||
- user will not have any edit capabilities in this mode (no drag/drop, no resize, nothing else)
|
||||
- the top toolbar should contain:
|
||||
- button to show/hide interface list pane
|
||||
- a time selection to move back to a specific time if possible (if server has this capabilities)
|
||||
- live button to come back to live data (if server has hist capabilities)
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/uopi/uopi/internal/config"
|
||||
"github.com/uopi/uopi/internal/server"
|
||||
"github.com/uopi/uopi/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "", "path to TOML config file (optional)")
|
||||
flag.Parse()
|
||||
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Error("failed to load config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
|
||||
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
webFS, err := fs.Sub(web.FS, "dist")
|
||||
if err != nil {
|
||||
log.Error("failed to sub web dist", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv := server.New(cfg.Server.Listen, webFS, log)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := srv.Start(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
# Functional Specification — uopi
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
uopi is a web-based HMI (Human-Machine Interface) for monitoring and controlling industrial/scientific systems, primarily EPICS-based control systems. It runs as a single server process and is accessed entirely through a web browser, making it suitable for SSH-tunnelled remote access.
|
||||
|
||||
---
|
||||
|
||||
## 2. Users and Roles
|
||||
|
||||
| Role | Description |
|
||||
|------|-------------|
|
||||
| Operator | Uses interfaces in View mode; can interact with controls but cannot edit layouts |
|
||||
| Engineer | Creates and edits interfaces in Edit mode; manages signal lists |
|
||||
| Administrator | Manages server configuration, data sources, and saved interfaces |
|
||||
|
||||
In the initial version all users share the same access level. Role-based access control is deferred to a future release.
|
||||
|
||||
---
|
||||
|
||||
## 3. System Modes
|
||||
|
||||
### 3.1 View Mode (default)
|
||||
|
||||
The default mode when opening the application.
|
||||
|
||||
**Interface list pane (left, collapsible)**
|
||||
- Displays all interfaces saved on the server, grouped into a tree by folder.
|
||||
- Right-click on an interface: options to open in Edit mode or clone it.
|
||||
- "New interface" button opens Edit mode with a blank canvas.
|
||||
- "Import" option loads an interface from a local XML file.
|
||||
|
||||
**HMI canvas (center)**
|
||||
- Renders the selected interface as a live, interactive panel.
|
||||
- Widgets display real-time data; controls (set-value, buttons) are active.
|
||||
- No drag, resize, or layout operations are possible in this mode.
|
||||
- Right-clicking any widget opens a context menu:
|
||||
- **Signal info** — floating window showing DS name, type, unit, range, current value and timestamp.
|
||||
- **Copy signal name** — copies the signal identifier to the clipboard.
|
||||
- **Export data to CSV** — downloads buffered/historical data for the signal(s) used by the widget.
|
||||
|
||||
**Top toolbar**
|
||||
- Show/hide interface list pane.
|
||||
- Time selector: navigate to a past timestamp (enabled only when the server has archive access for all signals on the canvas).
|
||||
- "Live" button: return to real-time data after historical navigation.
|
||||
|
||||
### 3.2 Edit Mode
|
||||
|
||||
Activated via the "New interface" button or by right-clicking an existing interface.
|
||||
|
||||
**Signal tree pane (left, resizable and collapsible)**
|
||||
- Shows all signals known to each connected data source.
|
||||
- Sources are shown as top-level nodes; signals are nested within.
|
||||
- User can add custom entries:
|
||||
- For EPICS: manually enter a PV name.
|
||||
- For Synthetic: define a new synthetic signal (see §5.2).
|
||||
- User can load a CSV file with columns `NAME, DataSource, DS_PARAMETERS` to import a batch of signals.
|
||||
- Filter/search box to narrow the list.
|
||||
|
||||
**Widget canvas (center)**
|
||||
- Free-form canvas where widgets can be placed at arbitrary pixel positions.
|
||||
- Background grid with optional snap-to-grid.
|
||||
|
||||
**Properties pane (right, collapsible)**
|
||||
- Appears when one or more widgets are selected.
|
||||
- Displays and edits all options for the selected widget (see §4).
|
||||
|
||||
**Top toolbar**
|
||||
- Show/hide signal pane.
|
||||
- Show/hide properties pane.
|
||||
- Undo / Redo (also Ctrl+Z / Ctrl+Shift+Z).
|
||||
- Save interface to server.
|
||||
- Load interface from server.
|
||||
- Export interface to local XML file.
|
||||
- Import interface from local XML file.
|
||||
- "Add text" tool — inserts a static text label.
|
||||
- "Add image" tool — inserts a static image (uploaded to server or embedded as base64).
|
||||
- "Add link" tool — inserts a button that opens another interface.
|
||||
|
||||
---
|
||||
|
||||
## 4. Widgets
|
||||
|
||||
### 4.1 Creating Widgets
|
||||
|
||||
Drag a signal from the signal tree and drop it onto the canvas. A picker appears showing all widget types compatible with the signal's data type (iconised). The user selects one and the widget is placed at the drop location with default size.
|
||||
|
||||
### 4.2 Selecting Widgets
|
||||
|
||||
- Single click: select one widget (deselects others).
|
||||
- Ctrl+click: add/remove a widget from the current selection.
|
||||
- Click-drag on empty canvas area: rubber-band area select.
|
||||
|
||||
When a widget is selected, a bounding box appears with:
|
||||
- 8 resize handles (corners + midpoints).
|
||||
- A delete button (×) in the top-right corner.
|
||||
- The widget can be moved by dragging its body.
|
||||
|
||||
### 4.3 Multi-selection Operations
|
||||
|
||||
When multiple widgets are selected:
|
||||
- Drag any selected widget to move them all together.
|
||||
- Del key deletes all selected widgets.
|
||||
- An align/distribute toolbar appears above the canvas with:
|
||||
- Align left / center horizontal / right.
|
||||
- Align top / center vertical / bottom.
|
||||
- Distribute evenly — by center spacing (horizontal/vertical).
|
||||
- Distribute evenly — by gap size (horizontal/vertical).
|
||||
|
||||
### 4.4 Widget Catalogue
|
||||
|
||||
| Widget | Compatible signal types | Description |
|
||||
|--------|-------------------------|-------------|
|
||||
| Text view | any scalar | Displays `name: value unit` |
|
||||
| Gauge | numeric scalar | Circular or arc gauge with configurable range |
|
||||
| Vertical bar | numeric scalar | Vertical level indicator |
|
||||
| Horizontal bar | numeric scalar | Horizontal level indicator |
|
||||
| Set value | numeric or string, writable | Shows `name: [input field] current_value unit` + Set button |
|
||||
| LED | boolean / numeric | Coloured indicator with configurable condition and label |
|
||||
| Multi-LED | integer (bitset) | One LED per bit with individual labels and conditions |
|
||||
| Button | writable | Sends a fixed value or command on click |
|
||||
| Plot | numeric scalar or array | Multi-signal plot; sub-types below |
|
||||
| Text label | — | Static text annotation |
|
||||
| Image | — | Static image |
|
||||
| Link | — | Button navigating to another interface |
|
||||
|
||||
**Plot sub-types:**
|
||||
|
||||
| Sub-type | Signal requirement |
|
||||
|----------|--------------------|
|
||||
| Time series | numeric scalar(s) |
|
||||
| FFT | 1-D numeric array |
|
||||
| Waterfall | 1-D numeric array (repeated) |
|
||||
| Histogram | numeric scalar(s) |
|
||||
| Bar chart | numeric scalar(s) |
|
||||
| Logic analyser | boolean / integer (bitset) |
|
||||
|
||||
### 4.5 Widget Properties (Properties Pane)
|
||||
|
||||
Common to all:
|
||||
- Label text, font size, text colour.
|
||||
- Position (X, Y) and size (W, H) — editable numerically.
|
||||
- Data source and signal name (read-only after creation; reassignable via drag).
|
||||
|
||||
Per type:
|
||||
- **Gauge / Bar**: min value, max value, alert thresholds with colours, unit label.
|
||||
- **LED / Multi-LED**: condition expression (e.g. `value > 0`), colours for true/false states.
|
||||
- **Plot**: plot sub-type selector, Y-axis range (auto or manual), time window duration, legend position, colour per signal, line style.
|
||||
- **Set value**: input type (numeric / string / enum), confirmation prompt toggle.
|
||||
- **Link**: target interface name.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data Sources
|
||||
|
||||
### 5.1 EPICS
|
||||
|
||||
- Connects to an EPICS environment via Channel Access (CA) or PVAccess (PVA).
|
||||
- On connect, retrieves full metadata from the PV name: data type, engineering units, display range (DRVL/DRVH, LOPR/HOPR), alarm limits, enum strings (for mbbi/mbbo records), read/write mode, acquisition mode.
|
||||
- Prefers monitor/subscription over polling. Falls back to polling only when monitors are unavailable.
|
||||
- Attempts to enumerate available PV names from the IOC (e.g. via PV lists or Channel Finder). Unknown PVs can still be manually added by the user.
|
||||
- When an EPICS Archive Appliance or Channel Archiver is configured, the server can satisfy historical data requests.
|
||||
|
||||
### 5.2 Synthetic
|
||||
|
||||
A signal defined by composing one or more input signals (from any data source) through a chain of processing functions.
|
||||
|
||||
**Built-in processing functions (non-exhaustive):**
|
||||
- Arithmetic: gain, offset, add, subtract, multiply, divide.
|
||||
- Signal processing: moving average (N samples or time window), RMS, bandpass filter (IIR/FIR), lowpass / highpass filter, derivative, integral.
|
||||
- FFT, inverse FFT.
|
||||
- Peak detection, threshold crossing.
|
||||
- Custom formula: inline expression (`a * sin(b) + c`).
|
||||
- Lua script block: arbitrary Lua code with access to input values and state.
|
||||
|
||||
**User workflow:**
|
||||
1. Click "New synthetic signal" in the signal tree.
|
||||
2. Name the signal and choose input signals.
|
||||
3. Build a processing pipeline by chaining function blocks (UI similar to a node graph or an ordered list).
|
||||
4. Optionally write a Lua snippet for custom logic.
|
||||
5. The synthetic signal appears in the tree and can be used like any other signal.
|
||||
|
||||
---
|
||||
|
||||
## 6. Interface Persistence
|
||||
|
||||
- Interfaces are saved to the server in XML format and are available to all connected clients.
|
||||
- Export/Import allows local file exchange of XML files.
|
||||
- The XML schema records: widget type, position, size, signal bindings, and all property values.
|
||||
|
||||
---
|
||||
|
||||
## 7. Historical Data Navigation
|
||||
|
||||
When the server has archive access for all signals on the current canvas:
|
||||
- The top toolbar shows a date/time picker.
|
||||
- Selecting a past time replays data from the archive into all widgets.
|
||||
- The "Live" button resumes real-time streaming.
|
||||
- Widgets that support time-axis (plots) show the historical range; point-value widgets show the value at the selected time.
|
||||
|
||||
---
|
||||
|
||||
## 8. Non-functional Requirements
|
||||
|
||||
| Requirement | Target |
|
||||
|-------------|--------|
|
||||
| Server binary | Single statically-linked executable; no runtime dependencies |
|
||||
| Target platform | Linux x86-64; also aarch64 optional |
|
||||
| Minimum server OS | RHEL/CentOS 7 (glibc 2.17) or equivalent |
|
||||
| Concurrent clients | ≥ 20 simultaneous browser clients |
|
||||
| Data fan-out latency | < 5 ms added latency vs. raw EPICS update rate |
|
||||
| Frontend responsiveness | 60 fps canvas rendering during live updates |
|
||||
| Screen DPI | Frontend adapts to device pixel ratio |
|
||||
@@ -0,0 +1,385 @@
|
||||
# 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:embed` packs 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` / `libCom` are straightforward.
|
||||
- `gopher-lua` provides 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) |
|
||||
| `go-epics/ca` or CGo wrapper | EPICS Channel Access |
|
||||
| `yuin/gopher-lua` | Lua 5.1 runtime for synthetic signals |
|
||||
| `gonum.org/v1/gonum` | DSP and math functions (FFT, filters) |
|
||||
| `encoding/xml` (stdlib) | Interface file serialisation |
|
||||
| `net/http` (stdlib) | HTTP server and static file serving |
|
||||
|
||||
### 1.2 Frontend — Svelte + TypeScript
|
||||
|
||||
**Rationale:**
|
||||
|
||||
- Svelte compiles to vanilla JS with no virtual DOM, giving the smallest bundle and lowest runtime overhead — essential for the 60 fps reactive feel required.
|
||||
- Fine-grained reactivity via Svelte stores keeps widget rendering decoupled from data arrival.
|
||||
- TypeScript catches signal subscription and widget property type errors at build time.
|
||||
|
||||
**Key dependencies:**
|
||||
|
||||
| Package | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------------------------ |
|
||||
| `svelte` + `vite` | Framework and build toolchain |
|
||||
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
|
||||
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
|
||||
| `konva` | 2-D canvas scene graph for the edit-mode widget canvas (handles, drag, resize) |
|
||||
| `svelte-konva` | Svelte bindings for Konva |
|
||||
|
||||
**Intentionally excluded:** React, Vue, WebGPU, jQuery.
|
||||
|
||||
---
|
||||
|
||||
## 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/PVA implementation
|
||||
│ │ └── synthetic/ # synthetic signal engine
|
||||
│ ├── lua/ # Lua sandbox helpers
|
||||
│ ├── dsp/ # DSP functions (wraps gonum + custom)
|
||||
│ ├── storage/ # interface XML read/write
|
||||
│ └── api/ # REST handler functions
|
||||
├── web/ # Svelte source
|
||||
│ ├── src/
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── ws.ts # WebSocket client + subscription manager
|
||||
│ │ │ ├── stores.ts # Svelte stores for signal values
|
||||
│ │ │ ├── widgets/ # one .svelte file per widget type
|
||||
│ │ │ └── editor/ # edit-mode canvas, toolbar, properties pane
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── +page.svelte # view mode
|
||||
│ │ │ └── edit/+page.svelte # edit mode
|
||||
│ │ └── app.html
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── docs/ # specs, work plan
|
||||
├── CLAUDE.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Architecture
|
||||
|
||||
### 3.1 DataSource Interface
|
||||
|
||||
```go
|
||||
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:**
|
||||
|
||||
```jsonc
|
||||
// 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 }
|
||||
```
|
||||
|
||||
**Server → Client messages:**
|
||||
|
||||
```jsonc
|
||||
// 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 }, ... ] }
|
||||
|
||||
// Error
|
||||
{ "type": "error", "code": "NOT_FOUND", "message": "Signal not found" }
|
||||
```
|
||||
|
||||
### 3.4 REST API
|
||||
|
||||
Base path: `/api/v1`
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------- | -------------------------------------------- |
|
||||
| GET | `/datasources` | List connected data sources and their status |
|
||||
| GET | `/signals?ds=epics` | List signals for a data source |
|
||||
| GET | `/signals/:ds/:name/meta` | Get full metadata for a signal |
|
||||
| GET | `/interfaces` | List saved interfaces |
|
||||
| POST | `/interfaces` | Create a new interface (body: XML) |
|
||||
| GET | `/interfaces/:id` | Download interface XML |
|
||||
| PUT | `/interfaces/:id` | Update interface XML |
|
||||
| DELETE | `/interfaces/:id` | Delete interface |
|
||||
| POST | `/interfaces/:id/clone` | Clone an interface |
|
||||
|
||||
### 3.5 EPICS Data Source
|
||||
|
||||
- Uses CGo bindings to EPICS Base `libca` (Channel Access). PVAccess support via `p4p` C library or a pure-Go PVA client if available.
|
||||
- Channel connections are lazy: a channel is connected on first Subscribe and disconnected when the broker releases it.
|
||||
- On connect, a `ca_get` retrieves full DBR_CTRL metadata (units, limits, enum strings).
|
||||
- `ca_add_event` sets 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.
|
||||
- Built-in node types implemented on top of `gonum/dsp` and custom code.
|
||||
- Lua nodes receive a sandboxed `lua.LState` with access to input values and a persistent state table.
|
||||
- Synthetic signal definitions are stored as part of the server configuration (JSON/TOML file), distinct from interface XML files.
|
||||
|
||||
### 3.7 Interface Storage
|
||||
|
||||
Interfaces are stored as XML files in a configurable directory on the server.
|
||||
|
||||
```xml
|
||||
<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"/>
|
||||
</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>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Architecture
|
||||
|
||||
### 4.1 WebSocket Client (`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 signal stores.
|
||||
|
||||
### 4.2 Signal Stores (`stores.ts`)
|
||||
|
||||
```typescript
|
||||
// One writable store per subscribed signal
|
||||
const signalStores = new Map<string, Writable<SignalValue>>();
|
||||
|
||||
function getStore(signal: string): Readable<SignalValue> { ... }
|
||||
```
|
||||
|
||||
Widgets import `getStore(signalName)` and bind to it reactively. Svelte's fine-grained reactivity ensures only the relevant widgets re-render on each update.
|
||||
|
||||
### 4.3 Edit Mode Canvas
|
||||
|
||||
The edit-mode canvas is implemented with **Konva.js** via `svelte-konva`:
|
||||
|
||||
- Each widget is a Konva Group containing its visual elements.
|
||||
- A `Transformer` node provides resize handles and enforces minimum sizes.
|
||||
- Drag-and-drop from the signal tree uses the HTML Drag-and-Drop API; on drop, the canvas coordinate is computed from `stage.getPointerPosition()`.
|
||||
- Undo/redo uses a command pattern: each mutating operation pushes an inverse operation onto a stack (max depth 100).
|
||||
- Align/distribute operations compute target positions geometrically and generate a single grouped undo entry.
|
||||
|
||||
### 4.4 Widget Rendering in View Mode
|
||||
|
||||
In view mode the Konva canvas is replaced with a lightweight SVG/HTML layer. Each widget is a Svelte component that:
|
||||
|
||||
1. Subscribes to its signal store(s) in `onMount`.
|
||||
2. Receives reactive updates and re-renders only its own DOM subtree.
|
||||
|
||||
Plot widgets (`uPlot` for time series, `ECharts` for others) manage their own canvas elements inside the Svelte component.
|
||||
|
||||
### 4.5 DPI Adaptation
|
||||
|
||||
- CSS uses `rem` units throughout for text.
|
||||
- Canvas elements read `window.devicePixelRatio` and set `canvas.width` / `canvas.height` accordingly while keeping CSS size fixed.
|
||||
- Konva's `Stage` is scaled by `devicePixelRatio` on init and on `resize`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Build System
|
||||
|
||||
### 5.1 Backend
|
||||
|
||||
```makefile
|
||||
# Build static binary (requires EPICS base installed or cross-compiled libca)
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Run a single test
|
||||
go test ./internal/broker/... -run TestFanOut
|
||||
```
|
||||
|
||||
EPICS `libca.a` is statically linked via `CGO_LDFLAGS` in `internal/datasource/epics/cgo.go`.
|
||||
|
||||
### 5.2 Frontend
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev # dev server at http://localhost:5173 (proxies /api to backend)
|
||||
npm run build # outputs to web/dist/
|
||||
npm run check # svelte-check type checking
|
||||
npm run lint # eslint + prettier
|
||||
```
|
||||
|
||||
### 5.3 Combined Build
|
||||
|
||||
A `Makefile` at the repo root:
|
||||
|
||||
```makefile
|
||||
.PHONY: all frontend backend clean
|
||||
|
||||
all: frontend backend
|
||||
|
||||
frontend:
|
||||
cd web && npm ci && npm run build
|
||||
|
||||
backend: frontend
|
||||
go build -ldflags="-s -w" -o dist/uopi ./cmd/uopi
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
cd web && npm run check
|
||||
|
||||
clean:
|
||||
rm -rf dist/ web/dist/
|
||||
```
|
||||
|
||||
The backend's `//go:embed web/dist` directive picks up the built frontend automatically.
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration
|
||||
|
||||
Server is configured via a TOML file (default: `uopi.toml`, overridable via `--config` flag):
|
||||
|
||||
```toml
|
||||
[server]
|
||||
listen = ":8080"
|
||||
storage_dir = "./interfaces"
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
ca_addr_list = "" # EPICS_CA_ADDR_LIST override
|
||||
archive_url = "" # EPICS Archive Appliance URL
|
||||
|
||||
[datasource.synthetic]
|
||||
enabled = true
|
||||
definitions_file = "./synthetic.json"
|
||||
```
|
||||
|
||||
All settings can also be overridden with environment variables: `UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| 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) |
|
||||
| Frontend | Svelte component tests via `vitest` + `@testing-library/svelte` |
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
- Lua sandbox: disable `os`, `io`, `package`, `debug` libraries; restrict `math` and `string` to 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.
|
||||
- No authentication in v1; intended for trusted LAN / SSH-tunnel deployment.
|
||||
|
||||
---
|
||||
|
||||
## 9. Non-goals (v1)
|
||||
|
||||
- User authentication and authorisation.
|
||||
- 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).
|
||||
@@ -0,0 +1,254 @@
|
||||
# Work Plan — uopi
|
||||
|
||||
## Phases Overview
|
||||
|
||||
| Phase | Name | Focus | Milestone |
|
||||
| ----- | --------------------- | ---------------------------------------------------------- | ----------------------------------------- |
|
||||
| 0 | Scaffold | Repo structure, build toolchain, CI | Empty binary serves embedded index page |
|
||||
| 1 | Core Backend | Broker, WebSocket protocol, REST skeleton | Live signal fan-out working end-to-end |
|
||||
| 2 | EPICS Data Source | CA connect, monitor, metadata, write | Real EPICS PVs visible in browser console |
|
||||
| 3 | Synthetic Data Source | DSP engine, Lua sandbox, signal composition | Synthetic signals computed and streamed |
|
||||
| 4 | Frontend Foundation | Svelte skeleton, WS client, signal stores, view mode shell | Subscribed values rendered in browser |
|
||||
| 5 | View Mode Widgets | All widget types rendered in view mode | Full interactive HMI panel |
|
||||
| 6 | Edit Mode | Konva canvas, drag-and-drop, resize, properties pane | Can build and save a panel |
|
||||
| 7 | Edit Mode — Advanced | Multi-select, align/distribute, undo/redo | Complete editor UX |
|
||||
| 8 | Historical Data | Archive integration, time navigation UI | Replay past data in widgets |
|
||||
| 9 | Signal Discovery | Signal tree, CSV import, EPICS Channel Finder | Usable without manual PV entry |
|
||||
| 10 | Hardening | Docs, packaging, integration tests, performance | Production-ready binary |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Scaffold
|
||||
|
||||
**Goal:** working build pipeline, empty binary that serves a placeholder page.
|
||||
|
||||
- [ ] Initialise Go module (`go mod init github.com/org/uopi`)
|
||||
- [ ] Create directory layout: `cmd/uopi/`, `internal/`, `web/`
|
||||
- [ ] Svelte + Vite + TypeScript project in `web/`
|
||||
- [ ] `//go:embed web/dist` in backend; `Makefile` builds frontend then backend
|
||||
- [ ] HTTP server on configurable port; serves embedded frontend
|
||||
- [ ] TOML config loading (`BurntSushi/toml` or `pelletier/go-toml`)
|
||||
- [ ] GitHub Actions CI: `make test` on push
|
||||
- [ ] `README.md` with quick-start instructions
|
||||
|
||||
**Done when:** `./dist/uopi` starts and serves an "under construction" page.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Core Backend
|
||||
|
||||
**Goal:** signal broker and WebSocket protocol working with a stub data source.
|
||||
|
||||
- [ ] Define `DataSource` interface (`internal/datasource/iface.go`)
|
||||
- [ ] Implement in-memory stub data source (sine wave emitter) for development
|
||||
- [ ] Implement `Broker` (`internal/broker/`):
|
||||
- Subscribe / unsubscribe with reference counting
|
||||
- Fan-out goroutine per signal
|
||||
- Clean teardown when last subscriber leaves
|
||||
- [ ] WebSocket handler (`internal/server/ws.go`):
|
||||
- `subscribe` / `unsubscribe` / `write` / `history` messages
|
||||
- Pushes `update` and `meta` messages to client
|
||||
- Graceful close on disconnect
|
||||
- [ ] REST API skeleton (`internal/api/`): datasources, signals, interfaces endpoints (stub responses)
|
||||
- [ ] Unit tests: broker fan-out, subscribe/unsubscribe lifecycle
|
||||
|
||||
**Done when:** a `wscat` client can subscribe to the stub source and receive timed updates.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — EPICS Data Source
|
||||
|
||||
**Goal:** real EPICS PVs readable and writable through the broker.
|
||||
|
||||
- [ ] CGo wrapper for EPICS `libca` (`internal/datasource/epics/`):
|
||||
- Context init and cleanup
|
||||
- `ca_create_channel` with connection callback
|
||||
- `ca_add_event` monitor with value callback
|
||||
- `ca_put` for writes
|
||||
- DBR_CTRL get for metadata (units, limits, enum strings)
|
||||
- [ ] Map CA DBR types to internal `DataType` enum
|
||||
- [ ] Implement `DataSource` interface for EPICS
|
||||
- [ ] Config: `ca_addr_list`, reconnect interval
|
||||
- [ ] Handle disconnected/reconnected channels gracefully (quality = Bad/Uncertain)
|
||||
- [ ] Integration test with SoftIOC (gated on `EPICS_BASE` env var)
|
||||
|
||||
**Done when:** a PV subscription round-trips from IOC → broker → WebSocket client with correct timestamp and units.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Synthetic Data Source
|
||||
|
||||
**Goal:** users can define computed signals from existing signals.
|
||||
|
||||
- [ ] Define synthetic signal definition schema (JSON)
|
||||
- [ ] Implement DAG evaluator: nodes re-evaluated on upstream change
|
||||
- [ ] Built-in node types (`internal/dsp/`):
|
||||
- Arithmetic: gain, offset, add, subtract, multiply, divide
|
||||
- Statistics: moving average (by count, by time), RMS
|
||||
- Filters: IIR lowpass/highpass/bandpass (via gonum or biquad)
|
||||
- Calculus: finite-difference derivative, cumulative integral
|
||||
- FFT / inverse FFT (gonum/dft)
|
||||
- Threshold / clamp
|
||||
- Custom expression (simple formula parser)
|
||||
- [ ] Lua node: sandboxed `gopher-lua` state per signal; inputs as globals
|
||||
- [ ] Load synthetic definitions from `synthetic.json` at startup
|
||||
- [ ] REST endpoint to CRUD synthetic signal definitions (persists to JSON file)
|
||||
- [ ] Unit tests for each DSP node type
|
||||
|
||||
**Done when:** a synthetic moving-average of an EPICS PV is visible in the WebSocket stream.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Frontend Foundation
|
||||
|
||||
**Goal:** Svelte app connects to WebSocket and reactively displays values.
|
||||
|
||||
- [ ] Svelte project structure: routes for view (`/`) and edit (`/edit`)
|
||||
- [ ] WebSocket client (`ws.ts`): connect, reconnect, message dispatch
|
||||
- [ ] Signal store factory (`stores.ts`): `getStore(signalName)` returns reactive store
|
||||
- [ ] Subscription manager: reference counting mirrors broker's
|
||||
- [ ] View mode shell: collapsible interface list pane + empty canvas area
|
||||
- [ ] Fetch and list interfaces from REST API
|
||||
- [ ] Load interface XML; parse widget definitions
|
||||
- [ ] Render a minimal text-view widget reactively from signal store
|
||||
- [ ] Basic responsive layout; DPI adaptation for canvas
|
||||
|
||||
**Done when:** loading a manually crafted XML interface displays live PV values.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — View Mode Widgets
|
||||
|
||||
**Goal:** all widget types rendered and interactive in view mode.
|
||||
|
||||
- [ ] Text view widget
|
||||
- [ ] Gauge widget (SVG arc, configurable range/thresholds)
|
||||
- [ ] Vertical / horizontal bar widget
|
||||
- [ ] LED widget (condition evaluator, configurable colours)
|
||||
- [ ] Multi-LED widget (bitset, per-bit labels)
|
||||
- [ ] Set-value widget (input + Set button; sends `write` over WS)
|
||||
- [ ] Button widget (sends fixed value on click)
|
||||
- [ ] Plot widget:
|
||||
- Time-series (uPlot, streaming buffer of N points)
|
||||
- FFT, waterfall, histogram, bar chart, logic analyser (ECharts)
|
||||
- Multi-signal support; legend
|
||||
- [ ] Text label (static)
|
||||
- [ ] Image widget (base64 or server URL)
|
||||
- [ ] Link widget (navigates to another interface)
|
||||
- [ ] Right-click context menu: signal info dialog, copy name, export CSV
|
||||
- [ ] Svelte component tests for each widget type (mock store)
|
||||
|
||||
**Done when:** a complete HMI panel with multiple widget types runs smoothly at 60 fps.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Edit Mode (Core)
|
||||
|
||||
**Goal:** users can build and save an interface from scratch.
|
||||
|
||||
- [ ] Konva stage setup in edit mode; `devicePixelRatio` scaling
|
||||
- [ ] Widget renderer adapter: same widget components rendered on Konva layer
|
||||
- [ ] Signal tree pane: fetch signal list, tree display, filter/search
|
||||
- [ ] Drag signal from tree → drop on canvas → widget type picker
|
||||
- [ ] Place widget at drop coordinates with default size
|
||||
- [ ] Single selection: bounding box + resize handles via Konva `Transformer`
|
||||
- [ ] Move widget by dragging body
|
||||
- [ ] Delete widget (× button or Del key)
|
||||
- [ ] Properties pane: common options (label, position, size)
|
||||
- [ ] Per-widget property editors (range, colour, plot type, etc.)
|
||||
- [ ] Save interface to server (POST/PUT XML)
|
||||
- [ ] Load interface from server (GET XML, populate canvas)
|
||||
- [ ] Export / import local XML file
|
||||
|
||||
**Done when:** an engineer can create a panel with 5+ widgets, save it, reload it, and see live data.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Edit Mode (Advanced)
|
||||
|
||||
**Goal:** complete editing UX matching the functional spec.
|
||||
|
||||
- [ ] Multi-select: Ctrl+click toggle; rubber-band area select
|
||||
- [ ] Group move: drag any selected widget to move all
|
||||
- [ ] Group delete: Del key on selection
|
||||
- [ ] Align toolbar: left / center-H / right / top / center-V / bottom
|
||||
- [ ] Distribute toolbar: evenly by center / by gap (H and V)
|
||||
- [ ] Undo / redo: command pattern, Ctrl+Z / Ctrl+Shift+Z
|
||||
- [ ] Snap-to-grid (optional, toggle in toolbar)
|
||||
- [ ] Add text label tool
|
||||
- [ ] Add image tool (upload to server or embed base64)
|
||||
- [ ] Add link tool
|
||||
- [ ] Collapsible signal tree pane and properties pane (toggle buttons)
|
||||
- [ ] Right-click on interface in list: Edit / Clone / Delete
|
||||
|
||||
**Done when:** the editor feels complete and the undo stack works reliably.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Historical Data
|
||||
|
||||
**Goal:** users can navigate to past timestamps using archive data.
|
||||
|
||||
- [ ] EPICS Archive Appliance HTTP API client (`internal/datasource/epics/archive.go`)
|
||||
- [ ] Broker `History()` dispatch: route to correct data source's `History()` impl
|
||||
- [ ] WebSocket `history` request / response handling
|
||||
- [ ] Frontend: time range picker in view mode toolbar
|
||||
- [ ] "Live" button: flushes history state, re-subscribes to live updates
|
||||
- [ ] Plot widget: switch between streaming and historical range mode
|
||||
- [ ] Point-value widgets: show value at selected timestamp
|
||||
|
||||
**Done when:** a plot can display 24 hours of archived data with a time slider.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Signal Discovery
|
||||
|
||||
**Goal:** users can find signals without knowing their names in advance.
|
||||
|
||||
- [ ] EPICS: attempt Channel Finder or `cainfo` enumeration; fall back to manual entry
|
||||
- [ ] Signal tree: lazy-load children on expand
|
||||
- [ ] Manual add custom PV (EPICS): text input in signal tree
|
||||
- [ ] New synthetic signal wizard: name, inputs, node graph UI
|
||||
- [ ] CSV import: parse `NAME, DataSource, DS_PARAMETERS`; add to tree
|
||||
|
||||
**Done when:** a new user can find and subscribe to a PV without prior knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Phase 10 — Hardening
|
||||
|
||||
**Goal:** production-quality binary, docs, performance validation.
|
||||
|
||||
- [ ] End-to-end integration tests (real SoftIOC, headless browser via Playwright)
|
||||
- [ ] Benchmark: 20 clients × 100 signals; verify < 5 ms fan-out latency
|
||||
- [ ] Benchmark: frontend at 10 Hz update rate; verify 60 fps
|
||||
- [ ] Static binary validation: run on RHEL 7 (Docker image `centos:7`)
|
||||
- [ ] Security: Lua sandbox audit; XML XXE protection; write-permission guard
|
||||
- [ ] Graceful shutdown: drain WS connections, flush pending writes
|
||||
- [ ] Structured logging (`log/slog`)
|
||||
- [ ] `/metrics` endpoint (Prometheus format) for server monitoring
|
||||
- [ ] Complete `README.md` and operator docs
|
||||
- [ ] Release: `goreleaser` config for Linux amd64 + arm64 binaries
|
||||
|
||||
**Done when:** binary passes integration tests on CentOS 7 container with a SoftIOC.
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
These are rough single-developer estimates. Parallel work across backend and frontend where possible will reduce calendar time.
|
||||
|
||||
| Phase | Effort |
|
||||
| --------- | ---------------- |
|
||||
| 0 | 1–2 days |
|
||||
| 1 | 3–5 days |
|
||||
| 2 | 1–2 weeks |
|
||||
| 3 | 1–2 weeks |
|
||||
| 4 | 3–5 days |
|
||||
| 5 | 2–3 weeks |
|
||||
| 6 | 2–3 weeks |
|
||||
| 7 | 1–2 weeks |
|
||||
| 8 | 1 week |
|
||||
| 9 | 1 week |
|
||||
| 10 | 1–2 weeks |
|
||||
| **Total** | **~14–20 weeks** |
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/uopi/uopi
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require github.com/BurntSushi/toml v1.6.0
|
||||
@@ -0,0 +1,2 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
@@ -0,0 +1,85 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
EPICS EPICSConfig `toml:"datasource.epics"`
|
||||
Synthetic SyntheticConfig `toml:"datasource.synthetic"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Listen string `toml:"listen"`
|
||||
StorageDir string `toml:"storage_dir"`
|
||||
}
|
||||
|
||||
type EPICSConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
CAAddrList string `toml:"ca_addr_list"`
|
||||
ArchiveURL string `toml:"archive_url"`
|
||||
}
|
||||
|
||||
type SyntheticConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
DefinitionsFile string `toml:"definitions_file"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Server: ServerConfig{
|
||||
Listen: ":8080",
|
||||
StorageDir: "./interfaces",
|
||||
},
|
||||
EPICS: EPICSConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
Synthetic: SyntheticConfig{
|
||||
Enabled: true,
|
||||
DefinitionsFile: "./synthetic.json",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads a TOML config file and applies environment variable overrides.
|
||||
// Environment variables use the prefix UOPI_ with __ as separator, e.g.
|
||||
// UOPI_SERVER_LISTEN=":9090".
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
|
||||
if path != "" {
|
||||
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("reading config %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
applyEnv(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_SERVER_LISTEN"); v != "" {
|
||||
cfg.Server.Listen = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_STORAGE_DIR"); v != "" {
|
||||
cfg.Server.StorageDir = v
|
||||
}
|
||||
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
||||
cfg.EPICS.CAAddrList = v
|
||||
}
|
||||
if v := env("UOPI_EPICS_ARCHIVE_URL"); v != "" {
|
||||
cfg.EPICS.ArchiveURL = v
|
||||
}
|
||||
if v := env("UOPI_SYNTHETIC_DEFINITIONS_FILE"); v != "" {
|
||||
cfg.Synthetic.DefinitionsFile = v
|
||||
}
|
||||
}
|
||||
|
||||
func env(key string) string {
|
||||
return strings.TrimSpace(os.Getenv(key))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates an HTTP server that serves the embedded frontend and a health
|
||||
// check endpoint. Additional routes (API, WebSocket) will be registered in
|
||||
// later phases.
|
||||
func New(addr string, webFS fs.FS, log *slog.Logger) *Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Serve embedded frontend for everything else
|
||||
mux.Handle("/", http.FileServerFS(webFS))
|
||||
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
},
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Start listens and serves until ctx is cancelled.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.log.Info("shutting down")
|
||||
return s.httpServer.Shutdown(shutCtx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[server]
|
||||
listen = ":8080"
|
||||
storage_dir = "./interfaces"
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
ca_addr_list = "" # overrides EPICS_CA_ADDR_LIST if set
|
||||
archive_url = "" # EPICS Archive Appliance base URL, e.g. http://archiver:17665/
|
||||
|
||||
[datasource.synthetic]
|
||||
enabled = true
|
||||
definitions_file = "./synthetic.json"
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Svelte + TS + Vite
|
||||
|
||||
This template should help get you started developing with Svelte and TypeScript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||
|
||||
## Need an official Svelte framework?
|
||||
|
||||
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||
|
||||
## Technical considerations
|
||||
|
||||
**Why use this over SvelteKit?**
|
||||
|
||||
- It brings its own routing solution which might not be preferable for some users.
|
||||
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||
|
||||
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||
|
||||
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||
|
||||
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
||||
|
||||
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
||||
|
||||
**Why include `.vscode/extensions.json`?**
|
||||
|
||||
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||
|
||||
**Why enable `allowJs` in the TS template?**
|
||||
|
||||
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
|
||||
|
||||
**Why is HMR not preserving my local component state?**
|
||||
|
||||
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
|
||||
|
||||
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||
|
||||
```ts
|
||||
// store.ts
|
||||
// An extremely simple external store
|
||||
import { writable } from 'svelte/store'
|
||||
export default writable(0)
|
||||
```
|
||||
@@ -0,0 +1,8 @@
|
||||
// Package web provides the embedded frontend assets built by Vite.
|
||||
// Run `npm run build` inside the web/ directory before building the binary.
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed dist
|
||||
var FS embed.FS
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1295
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/node": "^24.12.2",
|
||||
"svelte": "^5.55.4",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
let status = $state<'checking' | 'ok' | 'error'>('checking');
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const resp = await fetch('/healthz');
|
||||
status = resp.ok ? 'ok' : 'error';
|
||||
} catch {
|
||||
status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
checkHealth();
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<div class="logo">uopi</div>
|
||||
<h1>HMI Server</h1>
|
||||
<p class="subtitle">Web-based monitoring & control interface</p>
|
||||
|
||||
<div class="status" class:ok={status === 'ok'} class:error={status === 'error'}>
|
||||
{#if status === 'checking'}
|
||||
Connecting to server…
|
||||
{:else if status === 'ok'}
|
||||
Server reachable
|
||||
{:else}
|
||||
Cannot reach server
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="note">Frontend under construction. See <code>docs/WORK_PLAN.md</code> for progress.</p>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
background: #0f1117;
|
||||
color: #e2e8f0;
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -2px;
|
||||
color: #60a5fa;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #64748b;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
background: #1e293b;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #1e293b;
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
let count: number = $state(0)
|
||||
const increment = () => {
|
||||
count += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" class="counter" onclick={increment}>
|
||||
Count is {count}
|
||||
</button>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { mount } from 'svelte'
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,2 @@
|
||||
/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
|
||||
export default {}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"module": "esnext",
|
||||
"types": ["svelte", "vite/client"],
|
||||
"noEmit": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"moduleDetection": "force"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
'/healthz': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user