Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified public/synced/flow/docs/screenshots/debug-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/synced/flow/docs/screenshots/debug.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/synced/flow/docs/screenshots/editor-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/synced/flow/docs/screenshots/editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/synced/flow/docs/screenshots/review-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/synced/flow/docs/screenshots/review.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 0 additions & 4 deletions src/content/docs/cascade/running.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ description: "Run Cascade with Podman Quadlet or Helm on k3s, on SQLite or Postg
editUrl: false
---

:::note[Written against main]
This page follows `main` at dd8af77, and it differs from the copy in HotLoop Cascade 1.0.1, the latest release. Parts of it may be newer than what you have installed. The [release notes](/releases/) say what moved.
:::

On a Podman host, `deploy/quadlet` has systemd units. One file and a volume is
Cascade on SQLite; `deploy/quadlet/postgres` is the server, a worker and
PostgreSQL on a network of their own. Both start at boot, restart on failure and
Expand Down
4 changes: 0 additions & 4 deletions src/content/docs/cascade/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ description: "Cascade is released: what is in it, what is not in it yet, and whe
editUrl: false
---

:::note[Written against main]
This page follows `main` at dd8af77, and it differs from the copy in HotLoop Cascade 1.0.1, the latest release. Parts of it may be newer than what you have installed. The [release notes](/releases/) say what moved.
:::

1.0.1 is the release out today, and 1.0.0 is the one before it. 1.0.0 is the first
with the identity and governance work, each piece tried against the real server it
talks to: SAML (against Keycloak), LDAP and Active Directory (OpenLDAP and a Samba
Expand Down
12 changes: 6 additions & 6 deletions src/content/docs/flow/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ editUrl: false
---

Three ways in, all from the same image, `ghcr.io/hotloop-io/hotloop-flow`, built
for amd64, with arm64 following in 2.7.1:
for amd64, with arm64 following in 2.8.1:

| You have | Use |
|---|---|
Expand Down Expand Up @@ -70,7 +70,7 @@ To choose your own instead:

```bash
helm install line3-flows hotloop-flow/hotloop-flow \
--set auth.passwordHash="$(podman run --rm ghcr.io/hotloop-io/hotloop-flow:2.7.0 hash-password -password 'something-long')" \
--set auth.passwordHash="$(podman run --rm ghcr.io/hotloop-io/hotloop-flow:2.8.0 hash-password -password 'something-long')" \
--set hotloopFlow.credentialSecret="$(openssl rand -hex 32)"
```

Expand Down Expand Up @@ -191,7 +191,7 @@ so the unit can be copied around, committed and reviewed without carrying
anything that matters.

```bash
printf '%s' "$(podman run --rm ghcr.io/hotloop-io/hotloop-flow:2.7.0 \
printf '%s' "$(podman run --rm ghcr.io/hotloop-io/hotloop-flow:2.8.0 \
hash-password -password 'something-long')" | podman secret create hotloop-flow-admin-hash -
printf '%s' "$(openssl rand -hex 32)" | podman secret create hotloop-flow-credential-secret -
```
Expand All @@ -210,7 +210,7 @@ After=network-online.target

[Container]
ContainerName=hotloop-flow
Image=ghcr.io/hotloop-io/hotloop-flow:2.7.0
Image=ghcr.io/hotloop-io/hotloop-flow:2.8.0
PublishPort=1880:1880
Volume=hotloop-flow-data:/data
Environment=HOTLOOP_FLOW_ADMIN_USER=admin
Expand Down Expand Up @@ -271,9 +271,9 @@ To try it, not to run a line on:
```bash
podman run --rm -p 1880:1880 -v hotloop-flow-data:/data \
-e HOTLOOP_FLOW_ADMIN_USER=admin \
-e HOTLOOP_FLOW_ADMIN_PASSWORD_HASH="$(podman run --rm ghcr.io/hotloop-io/hotloop-flow:2.7.0 hash-password -password 'something-long')" \
-e HOTLOOP_FLOW_ADMIN_PASSWORD_HASH="$(podman run --rm ghcr.io/hotloop-io/hotloop-flow:2.8.0 hash-password -password 'something-long')" \
-e HOTLOOP_FLOW_CREDENTIAL_SECRET="$(openssl rand -hex 32)" \
ghcr.io/hotloop-io/hotloop-flow:2.7.0
ghcr.io/hotloop-io/hotloop-flow:2.8.0
```

Then open <http://localhost:1880>. That credential secret is new every time you
Expand Down
124 changes: 124 additions & 0 deletions src/content/docs/flow/flowlab.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,70 @@ Events for a run go out on the same socket as the editor's, as `diagram/<id>/nod
| `dia-serial-open`, `dia-serial-read`, `dia-serial-write` | A serial port. Reads end at a terminator or a byte count. |
| `dia-parse-reading`, `dia-stable` | Take a number and unit out of a line, and say whether the last few agree. |
| `dia-log-text` | Appends a record as a row of a text file, with a time column. |
| `dia-daq-read` | Reads samples from a data acquisition task on NI hardware, through NI's gRPC Device Server. See Data acquisition below. |
| `dia-mqtt-out` | Publishes its value through an MQTT server the flow file configures, the same connection the MQTT nodes use. Mark it durable and a message the broker misses is written to disk and sent, in order, when the broker is back, even across a restart. |
| `dia-log-tdms` | Appends a record to a TDMS file, one channel per field. See below. |
| `dia-control`, `dia-indicator`, `dia-chart` | The panel. |
| `dia-stats` | The mean, RMS, smallest, largest and standard deviation of an array. The standard deviation divides by n minus one, so it needs two readings. |
| `dia-integral` | The area under an array by the trapezoid rule, as one number or as the running area at each sample. |
| `dia-derivative` | The slope at each sample of an array. |
| `dia-filter` | A lowpass, highpass, bandpass or bandstop Butterworth filter of order 1 to 10 with a cutoff in hertz. |

### Filters

The filter node is a Butterworth filter: flat where it passes, no ripple, and 3 dB
down at the cutoff. It is designed from the analog filter by the bilinear transform
with the cutoff prewarped, so the 3 dB point lands on the frequency you typed, and it
runs as a chain of second-order sections so a high order stays accurate. A bandpass or
bandstop of order n has 2n poles. A cutoff has to be above zero and below half the
sample rate, and a design that cannot be built is listed in the problems before the run.
The sample rate is a property, or an input that wins over it. The filter keeps its
memory from call to call inside a run, so an array cut into pieces by a loop comes out
as if it had been filtered whole, and every new run starts from rest.

What it can't do: it is not a linear-phase filter, so it delays different frequencies
by different amounts, and it has no zero-phase mode that filters forward and back. Where
the timing between two signals has to line up, or a waveform's shape has to survive,
that is the wrong filter. Integral and derivative take the time between samples from a
`dt` input in seconds, a `rate` input in hertz, or the Samples per second property.

### Log to TDMS

`dia-log-tdms` writes the measurements a test bench takes to a TDMS file, the format
that data acquisition and analysis tools open. Wire a record into `row`. Each field
of the record is a channel in one group, so a record of `pressure`, `ok` and
`operator` makes three channels. A number is stored as a 64 bit float, true or false
as a bool, and anything else as text. A field that holds a list of numbers adds the
whole list, which is how a block of samples goes in. Settings:

| <span class="sr-only">Details</span> | <span class="sr-only">Details</span> |
|---|---|
| `path` | The file. A file that is already there is appended to, so one run after another builds one file. |
| `group` | The group the channels go in. |
| `columns` | The fields to log, in order. Empty logs every field, by name. |
| `properties` | A JSON object of properties for the file, such as `{"title": "bench 4", "station": 4}`, written when the run starts. |
| `sync` | On by default: each row reaches the disk before the node finishes. |

Each row is one segment, written in one piece. If the power goes in the middle of
one, the file shows where it was cut, a reader returns every row before it, and the
next run repairs the end of the file and goes on. A field's type is set by the first
row of a run. A later row that doesn't fit, such as text in a number column, goes
to the error wire as a bad input and the file is left alone. A missing number is
written as not-a-number.

To open the file, use any TDMS reader. The library behind the node, `internal/tdms`,
also writes an optional `.tdms_index` beside the file, and reads files without one.
Channel properties such as `unit_string` and the waveform properties `wf_start_time`,
`wf_increment` and `wf_samples` are supported by the library; the node does not set
them yet.

What the reader accepts: little and big endian segments, interleaved and contiguous
data, meta data left out of a segment when it hasn't changed, a new object list, 8, 16,
32 and 64 bit integers, 32 and 64 bit floats, bools, text and timestamps. What it
refuses, by name and without half reading the file: raw data written by a data
acquisition driver, scaled or compressed data, extended precision floats, fixed
point and complex numbers. What the writer writes: little endian, contiguous
data, and those same types. It does not write big endian.

### The error wire

Expand All @@ -92,12 +155,73 @@ loop's `err` body out and the loop stops on it, with the error on the loop's err
out. An error indicator on the panel goes red on a fault and clears when the next
value arrives with none.

### A target that goes down

A durable `dia-mqtt-out` sends straight to the broker while it answers. When it doesn't, the message is committed to Flow's durable store before the node finishes, and a pump delivers the queue, oldest first, when the link is back. Anything put in while others wait goes behind them, so nothing overtakes. Delivery from the queue is at least once: a message whose send timed out because the client had not noticed the link was gone can still be delivered when the client reconnects, so it can arrive twice, once for each send that timed out. The queue lives in the same store and under the same disk quota as a durable wire, and it drains after a restart with no run and no browser. Without durable, a message the broker misses is an error on the error wire.

### Serial

A port that goes away, such as a pulled cable or an unplugged adapter, is a fault on
the read's error wire. The next read tries to open it again, once a second, and counts
the loss and the return. Flow control lines are not offered.

### Data acquisition

`dia-daq-read` reads a task from NI hardware, such as a CompactDAQ chassis, through
NI's own open gRPC Device Server. Flow talks to that server over the network, so the
hardware can be on a different machine from Flow, and Flow needs no NI driver and no
vendor library of its own. It stays one static binary.

**On the machine the hardware is attached to**, install NI's DAQmx driver and NI's
gRPC Device Server (https://github.com/ni/grpc-device, MIT licensed). The server
listens on that machine only (`localhost`, port 31763) until its configuration file
says otherwise. To reach it from Flow on another machine, set `address` in its
`server_config.json` to `[::]` or the interface you want, and turn on TLS by setting
`server_cert`, `server_key`, and, for mutual TLS, `root_cert`, with the files in the
`certs` folder beside the configuration. NI's README has the details for the version
you run. Do not leave the server's default, unsecured port open on a plant network:
anyone who can reach it can run the hardware.

**In the node**, set:

* `address` to `host:port` of the server.
* `tls` to `off` (only for a server on the same machine), `on` (Flow checks the
server's certificate against `caFile`, or the system's authorities if that is
empty), or `mutual` (Flow also presents `certFile` and `keyFile`, which the server
checks against its `root_cert`). `serverName` is the name to look for in the
server's certificate when it is not the host in `address`.
* `device` and `channels` to name the hardware, such as `cDAQ1Mod1` and `ai0:3`, or a
list like `ai0, ai2`. `kind` is `voltage`, `current`, or `thermocouple`, with `min`
and `max` for the range you expect and `terminal` for the wiring. Thermocouples take
a `tcType` and a `unit` and use the module's own cold junction compensation.
* `mode`, `rate`, and `samples`. Finite mode takes one acquisition of `samples` per
channel at `rate` on every call, starting it again each time. Continuous mode starts
the task on the first call and each call reads the next `samples`, so a node in a
loop body reads a stream block by block. `trigger` starts the task on a digital edge
at a terminal such as `/cDAQ1/PFI0`.

`y` is the samples as an array for one channel, or an array of arrays, one per channel
in the order you named them. `dt` is the seconds between samples, one over the rate, so
a block's length in seconds is its sample count times `dt`: 1,000 samples at 1,000
samples a second cover 1.000 s. The device's own clock times the samples, as it always
does. Flow only moves the data. A refusal from the server or the driver comes out on
`err` with the driver's own code and text, and a server that does not answer in
`callTimeoutSec` is a timeout. The task lives for the run and is cleared on the server
when the run ends, so the hardware is free again.

What is done: analog voltage in, analog current in, and thermocouple temperature in,
finite or continuous, with a sample clock and an optional digital start trigger. What
is not: digital in and out and counters use different read calls and are not built, and
neither are writes, other trigger types, or scaling beyond what the module does.

**This has not been run against NI's server.** There is no NI hardware, driver, or
server in our lab. Every test runs against a stand-in server we wrote, which speaks
the same calls and answers with a sine wave. What backs the wire format is a test that
reads NI's own protocol files, kept in `third_party/ni-grpc-device/`, and checks that
every message the client sends and reads has the same field numbers, names, and types.
That shows we say the right words. It does not show NI's server behaves the way the
stand-in does.

## The panel

`/panel/<diagram id>` on a runtime opens a page of the diagram's controls,
Expand Down
10 changes: 5 additions & 5 deletions src/content/docs/flow/install.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
# generated by scripts/sync-docs.mjs
title: "Install HotLoop Flow with Podman or Helm"
description: "Run HotLoop Flow 2.7.0 with Podman or Quadlet, on Kubernetes with Helm, or from source. It starts locked, so set an admin password first."
description: "Run HotLoop Flow 2.8.0 with Podman or Quadlet, on Kubernetes with Helm, or from source. It starts locked, so set an admin password first."
sidebar:
label: "Install"
editUrl: false
---

HotLoop Flow 2.7.0 is current. It ships as `ghcr.io/hotloop-io/hotloop-flow:2.7.0`, a 45.34 MB image on amd64, with the arm64 build following in 2.7.1, plus a Helm chart and source you can build yourself. The image is distroless, runs as a non-root user, and has no shell in it, so there's nothing to drop into, for you or for anybody who shouldn't be there. There's no Docker or Compose path on this page: Podman for one box, Quadlet to make it a service, Helm for a cluster.
HotLoop Flow 2.8.0 is current. It ships as `ghcr.io/hotloop-io/hotloop-flow:2.8.0`, a 45.64 MB image on amd64, with the arm64 build following in 2.8.1, plus a Helm chart and source you can build yourself. The image is distroless, runs as a non-root user, and has no shell in it, so there's nothing to drop into, for you or for anybody who shouldn't be there. There's no Docker or Compose path on this page: Podman for one box, Quadlet to make it a service, Helm for a cluster.

Flow starts locked. It refuses to run without an admin account, because a flow can run commands. And it refuses to run without a credential secret, which encrypts every broker and database password stored with your flows. Every path below sets both.

Expand All @@ -18,7 +18,7 @@ From 2.2.0 context and durable wires live in a SQLite file, `state.db`, on the d
Make a password hash first. The image has no shell, so hashing is a subcommand of the binary itself, and it refuses anything under eight characters.

```bash
podman run --rm ghcr.io/hotloop-io/hotloop-flow:2.7.0 hash-password -password 'something-long'
podman run --rm ghcr.io/hotloop-io/hotloop-flow:2.8.0 hash-password -password 'something-long'
```

Then make the credential secret, and save it somewhere you'll still find it in a year:
Expand All @@ -34,7 +34,7 @@ podman run -d --name hotloop-flow -p 1880:1880 -v hotloop-flow-data:/data \
-e HOTLOOP_FLOW_ADMIN_USER=admin \
-e HOTLOOP_FLOW_ADMIN_PASSWORD_HASH='<the hash>' \
-e HOTLOOP_FLOW_CREDENTIAL_SECRET='<the secret>' \
ghcr.io/hotloop-io/hotloop-flow:2.7.0
ghcr.io/hotloop-io/hotloop-flow:2.8.0
```

Open `http://localhost:1880/` and sign in as `admin`. `/health` answers with the version once the process is up, and `/ready` says whether the runtime actually started.
Expand Down Expand Up @@ -64,7 +64,7 @@ After=network-online.target
Wants=network-online.target

[Container]
Image=ghcr.io/hotloop-io/hotloop-flow:2.7.0
Image=ghcr.io/hotloop-io/hotloop-flow:2.8.0
ContainerName=hotloop-flow
PublishPort=1880:1880
Volume=hotloop-flow-data:/data
Expand Down
28 changes: 14 additions & 14 deletions src/content/docs/flow/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ What that means on a bad night:
It runs your existing flows unchanged, too. That's [further down](/flow/migrating-from-node-red/),
because it's a feature, not the point.

Current release is `2.7.0`. The image is `ghcr.io/hotloop-io/hotloop-flow:2.7.0`
for amd64 (arm64 follows in 2.7.1), the Helm repo is `https://hotloop.io/hotloop-flow/`, and
Current release is `2.8.0`. The image is `ghcr.io/hotloop-io/hotloop-flow:2.8.0`
for amd64 (arm64 follows in 2.8.1), the Helm repo is `https://hotloop.io/hotloop-flow/`, and
[docs/deploying.md](/flow/deploying/) has Kubernetes, Podman and Quadlet. About
104,000 lines of Go, a little under 40 percent of it tests, 79 node types for messages and 21 for FlowLab diagrams, and the race
104,000 lines of Go, a little under 40 percent of it tests, 79 node types for messages and 28 for FlowLab diagrams, and the race
detector comes back clean on every package. Home and lab use is free, so is use by schools, nonprofits and government, and
business use is free through EmberNet, under HotLoop's Business Source License.

Expand All @@ -57,25 +57,25 @@ re-measured below. Three runs each, and the range is what's printed.

| <span class="sr-only">Details</span> | <span class="sr-only">Details</span> |
|---|---|
| Binary, linux/amd64, static | **41.4 MB** |
| Image, `2.7.0` amd64, as `podman image inspect` reports it | **45.34 MB** |
| Binary, linux/amd64, static | **41.7 MB** |
| Image, `2.8.0` amd64, as `podman image inspect` reports it | **45.64 MB** |
| Start to answering its first request | **0.05 s** |
| RSS, idle | **16.4 to 16.7 MiB** |
| RSS, peak under the HTTP load below | **26.4 to 28.5 MiB** |
| HTTP, five-node flow, 8 connections | **13.0k to 15.3k req/s** |
| HTTP latency p50 / p99 | **0.39 to 0.41 ms / 1.78 to 4.03 ms** |
| Engine, five-node chain, no I/O | **2.20 to 3.11 µs a message**, 3.9 KB allocated |

The binary row is the 2.7.0 tree built the way the table's heading says,
`CGO_ENABLED=0 go build -trimpath -ldflags="-s -w"`, on 2026-11-01: 41,402,530
bytes, on The Beast. The 2.5.0 number, 41,144,480, was taken on another machine,
so I don't read the gap between them as growth. On The Beast, FlowLab added about
370 KB to the binary: 41,025,698 before it and 41,398,434 after. The image row is the
`2.7.0` amd64 image built from the release tree the way the release workflow
builds it, `podman build --platform linux/amd64 --build-arg VERSION=v2.7.0 .`, read
The binary row is the 2.8.0 tree built the way the table's heading says,
`CGO_ENABLED=0 go build -trimpath -ldflags="-s -w"`, on 2026-11-01: 41,693,346
bytes, on The Beast. The 2.7.0 number, 41,402,530, was taken on the same machine the
same way, so the 290 KB between them is what the statistics, integral, derivative and
filter nodes, the DAQ client, the MQTT output and the TDMS log added. The image row is the
`2.8.0` amd64 image built from the release tree the way the release workflow
builds it, `podman build --platform linux/amd64 --build-arg VERSION=v2.8.0 .`, read
on 2026-11-01 with `podman image inspect <that image> --format '{{.Size}}'`
(45,342,406 bytes). It was not read from ghcr.io, because the tag wasn't pushed
yet. It was 44.95 MB at `2.5.0`, 44.8 MB at `2.3.0`, 39.1 MB at `2.2.0`, 32.1 MB at
(45,635,783 bytes). It was not read from ghcr.io, because the tag wasn't pushed
yet. It was 45.34 MB at `2.7.0`, 44.95 MB at `2.5.0`, 44.8 MB at `2.3.0`, 39.1 MB at `2.2.0`, 32.1 MB at
`2.1.0` and 25.4 MB at `2.0.5`. The other rows are still the 2026-10-02 build.

The start, memory and HTTP rows are one command against
Expand Down
Loading
Loading