From 3206489cdfd3041f7d03f599341e117d05ef6fec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 14:55:50 -0400 Subject: [PATCH 1/5] 20261007 - Group the Configuration page around how an owner uses a node The Configuration page had grown a section at a time, and its order no longer matched how an owner works through a node. Node Claim sat near the bottom of a long Administration group, the centre frequency lived in Capture while the tower it belongs to was two sections up, and the side menu's group headings looked the same as the links under them. The page is now five groups: Node (Identifiers, Mode, Node Claim, Network), Radar (Cached Towers, Tower, Capture, Tracking), ADS-B (ADS-B Truth, tar1090), Other (SSH Access, Setup Wizard) and Support (Cloud Services, Remote Support, How We Reach You). Each group is titled in the page itself with a rule running out to the right, the side menu's group titles carry the same rule, and a section has one name, written the same in the menu, in its heading and in the wizard hints that point at it. What was "Tower" is "Cached Towers" and what was "Location" is "Tower". Center Frequency moves into Tower's transmitter block, after Name, because it is a property of the tower and not of the capture. Tower shows the receiver's name read-only; it is changed under Identifiers, which also shows the server's identifier for the node beside the local one. The form Apply changes submits now wraps exactly the Radar and ADS-B groups. That is the property the regrouping must not break: a radar field left outside the form is missing from the POST, which the server reads as that setting having been cleared. tests/test_config_layout.py is new and holds the order, the group titles, the form's membership and the centre frequency's new home. test_app.py, test_claim.py, test_contact.py and test_node_name.py follow the renamed sections. Docs: config-editor.md gains a Page layout section and uses the new names throughout; setup-wizard.md, device-state-and-telemetry.md and remote-access.md follow the renames. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AoHZhvWU7Ge89Cm6YgrExB --- docs/features/config-editor.md | 155 ++++- docs/features/device-state-and-telemetry.md | 4 +- docs/features/remote-access.md | 2 +- docs/features/setup-wizard.md | 4 +- src/routes/config.py | 5 +- static/common.css | 28 +- templates/config.html | 678 +++++++++++--------- templates/setup/_claim.html | 2 +- templates/setup/_contact.html | 2 +- tests/test_app.py | 9 +- tests/test_claim.py | 4 +- tests/test_config_layout.py | 270 ++++++++ tests/test_contact.py | 4 +- tests/test_node_name.py | 2 +- 14 files changed, 825 insertions(+), 344 deletions(-) create mode 100644 tests/test_config_layout.py diff --git a/docs/features/config-editor.md b/docs/features/config-editor.md index 0f08194..b3622d1 100644 --- a/docs/features/config-editor.md +++ b/docs/features/config-editor.md @@ -1,7 +1,8 @@ # Config editor -The `/config` page is a form over the node's radar configuration (capture, location, ADS-B truth, -tar1090 and retina-tracker settings) plus a set of administration panels that save on their own. +The `/config` page is a form over the node's radar configuration (capture, location, ADS-B Truth, +tar1090 and retina-tracker settings) plus a set of panels that save on their own. See +[Page layout](#page-layout) for how the two are arranged. The radar fields are generated from Pydantic models, display the merged `config.yml` that the stack is actually running, and write only the user's overrides to `user.yml`. Saving is followed automatically by an apply, which runs retina-node's config-merger and restarts the stack. @@ -14,8 +15,8 @@ automatically by an apply, which runs retina-node's config-merger and restarts t | [src/config_manager.py](../../src/config_manager.py) | `ConfigManager`: reads `config.yml` and `user.yml`, flattens nested YAML into form fields and back, parses the posted form, computes the overrides to write. | | [src/form_utils.py](../../src/form_utils.py) | `schema_to_form_fields`: turns a Pydantic model plus current values into the field dicts the template renders. Works on Pydantic v1 and v2. | | [src/routes/config.py](../../src/routes/config.py) | Blueprint: `/config`, `/config/save`, `/config/apply`, `/config/apply/status`, `/config/rf-status`, plus `/ssh-keys`, `/ssh-keys/delete` and `/node-name`. Cross-field validation (location, ADS-B source). | -| [templates/config.html](../../templates/config.html) | The page: `render_field` macro, hand-built Location and tar1090 sections, tower presets, mode switch, save bar, auto-apply, peak meter, Auto-Calibrate modal, administration panels. | -| [src/routes/towers.py](../../src/routes/towers.py) | `/towers/cache/add` and `/towers/cache/remove`, used by the Tower section. | +| [templates/config.html](../../templates/config.html) | The page: `render_field` macro, hand-built Tower and tar1090 sections, tower presets, mode switch, save bar, auto-apply, peak meter, Auto-Calibrate modal, administration panels. | +| [src/routes/towers.py](../../src/routes/towers.py) | `/towers/cache/add` and `/towers/cache/remove`, used by the Cached Towers section. | | [static/calibrate.js](../../static/calibrate.js) | `RetinaCalibrate`, shared with the setup wizard's calibrate step. | | retina-node `config-merger/script/merge_config.py` | Builds `config.yml` from `default.yml`, `user.yml` and `forced.yml` (separate repo). | @@ -96,7 +97,7 @@ type (a port rendered as a text box). The repo's ruff config enforces `X | None` The `render_field` macro in `config.html` draws each dict. A readonly text field is shown locked with a hidden input so its value still posts; a readonly checkbox posts through a hidden input too. -Location and tar1090 are hand-built rather than rendered through the macro (see +Tower and tar1090 are hand-built rather than rendered through the macro (see [Validation](#validation) for what that costs). The Pydantic `Field(description=...)` strings are UI copy. Changing them changes the page. @@ -113,15 +114,85 @@ This shapes the schema in a few ways: checks such as `CONTACT_COUNTRY_PATTERN` and `CLAIM_EMAIL_PATTERN` are applied in `routes/setup.py`. Length caps are portable and stay on the model. +## Page layout + +The side nav and the page share one order, in five groups: + +| Group | Sections | Saved by | +| --- | --- | --- | +| Node | Identifiers, Mode, Node Claim, Network | Each on its own (Mode on Apply changes, see [Mode switch](#mode-switch)) | +| Radar | Cached Towers, Tower, Capture, Tracking | Apply changes | +| ADS-B | ADS-B Truth, tar1090 | Apply changes | +| Other | SSH Access, Setup Wizard | Each on its own | +| Support | Cloud Services, Remote Support, How We Reach You | Each on its own | + +The groups are how an owner thinks about the node (what it is, what it listens to, what it is +checked against, who can help with it), not how the settings are stored. Support is last: it is +set once and rarely revisited. + +`#configForm`, the form Apply changes submits, wraps the Radar and ADS-B groups and nothing else. +Every other section has inputs or forms of its own, so it has to stay outside: a form cannot +contain another form, and an Enter key in a contact box would otherwise submit the radar settings. +A new section that saves on its own goes in a group outside the form, and a new radar section +goes inside it, or its fields are silently left out of the POST, which the server reads as those +settings having been cleared. `tests/test_config_layout.py` checks both. + +Each group's title is also in the page itself, above the group's first section: an `h2.cfg-group` +with a rule running out to the right edge, so the page shows where one group ends and the next +starts without the side nav (which is hidden on a narrow window). The side nav's own group +titles carry the same rule, so they read as dividers and not as one more link. The titles are written twice, +in the side nav and in the page, and `tests/test_config_layout.py` holds both to the same list. + +Section names are in title case (Node Claim, SSH Access), the same in the side nav, on the +section's own heading and wherever the page or the setup wizard points an owner at a section. + +The scrollspy at the bottom of `config.html` lists the section ids in page order and marks the +first one on screen as active, so a section added to the page has to be added there as well. +A link that has just been clicked stays active until the owner scrolls by hand: the last +sections on the page are short, so the page cannot scroll them to the top, and "first one on +screen" would light up an earlier section than the one that was asked for. +A side-nav click scrolls its section to the top of the window; `scroll-margin-top` on +`.cfg-section` stops it short of the sticky banner, which would otherwise cover the heading. +A group's first section stops a little lower still, so its group title stays in view above it. + ## Sections and schema models | Section | Fields | Notes | | --- | --- | --- | -| Capture | `fs`, `fc`, `device_type` (readonly), `device_agcSetPoint`, `device_gainReductionA/B`, `device_lnaState`, `device_dabNotch`, `device_rfNotch`, `device_bandwidthNumber` | `fs` and `device_bandwidthNumber` are `Literal` selects. | -| Location | `rx_*` and `tx_*` latitude, longitude, altitude, name | All optional. See [Location: all or nothing](#location-all-or-nothing). | -| ADS-B truth | `enabled`, `tar1090`, `adsb2dd`, `delay_tolerance`, `doppler_tolerance` | Stored under `truth.adsb`. | +| Cached Towers | none of its own | Fills in the Tower section's fields and edits the tower cache. See [Tower presets](#tower-presets). | +| Tower | `rx_*` and `tx_*` latitude, longitude, altitude, name, and Capture's `fc` | The location model's fields plus the center frequency. See [Tower section](#tower-section). | +| Capture | `fs`, `device_type` (readonly), `device_agcSetPoint`, `device_gainReductionA/B`, `device_lnaState`, `device_dabNotch`, `device_rfNotch`, `device_bandwidthNumber` | `fs` and `device_bandwidthNumber` are `Literal` selects. `fc` belongs to this model but is shown in the Tower section. | +| ADS-B Truth | `enabled`, `tar1090`, `adsb2dd`, `delay_tolerance`, `doppler_tolerance` | Stored under `truth.adsb`. | | tar1090 | `adsb_source_host/port/protocol`, `adsblol_fallback`, `adsblol_radius` | See [ADS-B source](#ads-b-source). | -| Retina Tracker | `min_snr` | Tunes the retina-tracker sidecar, not blah2's built-in tracker (`process.tracker`). Reaches the sidecar via config-merger's `retina-tracker.yaml`. | +| Tracking | `min_snr` | Tunes the retina-tracker sidecar, not blah2's built-in tracker (`process.tracker`). Reaches the sidecar via config-merger's `retina-tracker.yaml`. Its own section, straight after Capture: it governs what is done with detections afterwards, not how the signal is captured. Posted as `retina_tracker.min_snr`. | + +### Tower section + +The section titled Tower is the `location` part of the config: where the receiver stands and +which transmitter it listens to. It is named for what an owner is choosing there, the tower. Its +fields are the location model's (all optional, see [Location: all or nothing](#location-all-or-nothing)), +with the receiver name read-only (see [Receiver name](#receiver-name)). + +The center frequency is shown here too, in the Transmitter block between the tower's name and +its latitude, because it is a property of the tower: picking a different tower changes it along +with the coordinates. It is still a capture setting. The input is named `capture.fc`, the Capture +loop in `config.html` skips it, and it is validated by `CaptureFormConfig` and stored under +`capture`, so nothing on the server knows it moved. + +The section that lists the wizard's search results, and was called Tower before, is Cached Towers +(see [Tower presets](#tower-presets)). + +### Receiver name + +The receiver is the node, so `location.rx.name` is not a second name to keep. The Tower +section shows the node's name (set under Identifiers) read-only, and posts it as +`location.rx_name` in a hidden field, so Apply changes writes it to the radar config. An unnamed +node goes by its node ID, which is also what the setup wizard writes (`/towers/select`). + +Renaming the node under Identifiers saves at once and updates what Tower shows and will post, +but the radar config's copy only changes with the next Apply changes. Nothing reads that copy to +address the node, so the lag is harmless; it is not worth a stack restart on every rename, which +is what keeping the two in step immediately would cost. ### Transmitter name length @@ -161,9 +232,9 @@ A special `_form` key carries a message that belongs to no field. On any error t re-rendered from the submitted values (not from disk), so the user's input is kept. The banner at the top shows `config_errors['_form']` if present, otherwise "Please fix the -highlighted fields below". The `render_field` macro highlights its own field. The Location and +highlighted fields below". The `render_field` macro highlights its own field. The Tower and tar1090 sections are hand-built, so every input in them looks up its own `config_errors` key. -Without that the banner claimed fields were highlighted when none were. All eight Location inputs +Without that the banner claimed fields were highlighted when none were. All nine Tower inputs (the eight location fields and the center frequency) are wired, not only the ones that can fail today, so a future constraint cannot silently reintroduce the problem. @@ -261,7 +332,7 @@ for the apply service. | Guard | Where | Why | | --- | --- | --- | -| Setup wizard in progress | `routes/config.py:_check_wizard_not_active` redirects every route in the blueprint to `/set-up`, except `/config/apply/status` | The wizard's tower step polls that status. A redirect would hand `fetch()` an HTML page: the request succeeds, `.json()` rejects, and the step re-polls forever with its spinner up and skip button hidden. Matched exactly, not by prefix, because `/config/apply` and `/config/save` mutate and must stay blocked. The same rule applies to `_CALIBRATION_ALLOWED_PREFIXES` in `app.py`. | +| Setup Wizard in progress | `routes/config.py:_check_wizard_not_active` redirects every route in the blueprint to `/set-up`, except `/config/apply/status` | The wizard's tower step polls that status. A redirect would hand `fetch()` an HTML page: the request succeeds, `.json()` rejects, and the step re-polls forever with its spinner up and skip button hidden. Matched exactly, not by prefix, because `/config/apply` and `/config/save` mutate and must stay blocked. The same rule applies to `_CALIBRATION_ALLOWED_PREFIXES` in `app.py`. | | Auto-Calibrate running | `/config/save` (form-level error) and `ApplyService.request()` (raises `ConfigChangeRefused`, 409) | The save is refused as well as the apply. Guarding only the apply would leave changes in `user.yml` that were never applied, and they would be swept silently into the next merge, including the one `/calibrate/apply` runs after a successful calibration. The apply check lives in `request()` so no future route can skip it. | | Mender install in progress | `/config/apply`, 409 | The install replaces the compose manifests config-merger runs against, and mender-update's own docker commands are outside the restart lock, so the two can genuinely run at once. The apply would also report success while skipping the restart, since the install sets `mode.txt` to `spectrum`. An install can end in rollback or reboot, so the user is asked to retry rather than having the apply held across it. | | retina-node missing | `/config/apply`, 400 | Nothing to apply to. | @@ -280,7 +351,7 @@ value read back after setting it, so a select that rejects a value is not record ## Tower presets -The Tower section offers the towers cached by the setup wizard's search (`towers-cache.json`, read +The Cached Towers section offers the towers cached by the setup wizard's search (`towers-cache.json`, read through `device_state.get_towers_cache`). Two different save models share the section: - **Picking a preset** only fills form fields: `location.tx_name` (callsign, else name), @@ -450,7 +521,7 @@ Vocabulary and interpretation shared with the wizard (phase labels, `diagnose`, ### Adopting the persisted values -The Capture and Location fields were rendered from `config.yml` before the run, so after a save +The Capture and Tower fields were rendered from `config.yml` before the run, so after a save they show the old tuning. That is not only confusing (a user saw the calibrated gains in the modal and the old ones in the form), it is destructive: Save posts every field. Before the merge finishes, `config.yml` still holds the old values, so the stale submission matches it and @@ -466,27 +537,31 @@ save bar does not claim changes that are already on disk. ## Administration sections -Everything after the radar form (`#configForm` closes after Retina Tracker) saves on its own -endpoint, immediately, and is not part of Apply changes. +Everything that is not a radar field saves on its own endpoint, immediately, and is not part of +Apply changes. These sections are spread across the Node, Support and Other groups (see +[Page layout](#page-layout)); what they share is how they save. -| Section | Endpoint | Notes | -| --- | --- | --- | -| This node | `POST /node-name` | See below. | -| SSH access | `POST /ssh-keys`, `/ssh-keys/delete` | Form posts that redirect back to `/config`. | -| Remote support | `/remote-access/toggle`, `/remote-access/shell` | Two independent toggles. | -| How we reach you | `POST /set-up/contact` | Optional owner contact details. | -| Node claim | `POST /set-up/claim` | The address that owns the node. | -| Cloud services | (page script) | Disabling also switches off Remote support. | -| Setup wizard | link to `/set-up` | | -| Network | (page script) | Status, scan, manual entry, connect. | - -The Remote support, contact and claim template variables come from the blueprint's context +| Section | Group | Endpoint | Notes | +| --- | --- | --- | --- | +| Identifiers | Node | `POST /node-name` | See below. | +| Node Claim | Node | `POST /set-up/claim` | The address that owns the node. | +| Network | Node | (page script) | Status, scan, manual entry, connect. | +| SSH Access | Other | `POST /ssh-keys`, `/ssh-keys/delete` | Form posts that redirect back to `/config`. | +| Setup Wizard | Other | link to `/set-up` | | +| Cloud Services | Support | (page script) | Disabling also switches off Remote Support. | +| Remote Support | Support | `/remote-access/toggle`, `/remote-access/shell` | Two independent toggles. | +| How We Reach You | Support | `POST /set-up/contact` | Optional owner contact details. | + +The Remote Support, contact and claim template variables come from the blueprint's context processor `_remote_access_context`, not from each `render_template` call. `config.html` has two render points (`/config` and the validation-error branch of `/config/save`), and passing the same arguments by hand is how the second one once shipped without them. A context processor cannot drift when a third render point appears. -### This node +### Identifiers + +The section an owner goes to for "what is this node called": its name, its address, and the two +identifiers it is known by. Only the name can be changed. The node name is only a label. The fleet page shows it on the node's card instead of the `ret` identifier, and it reaches other nodes through the DNS-SD TXT record. Nothing @@ -496,7 +571,19 @@ The address shown is the node's own `http://.local`, deliberately not ` name is answered by every node on the network at once, so checking it proves only that some node is reachable, and on a network with more than one node it leads to the fleet list instead. -### Remote support +The two identifiers are shown together because owners meet both and need telling apart: + +| Shown as | Value | Where it comes from | +| --- | --- | --- | +| Node local identifier | `ret<8 hex>`, the `node_id` | The board's serial, through Mender's device identity. Fixed for the life of the board. It is the name in the node's address and what the node is listed by until it is named. | +| Node server identifier | The `node_ref` | Assigned by the server when the node registers, and learned only through retina-telemetry's status document (see [device-state-and-telemetry.md](device-state-and-telemetry.md#node-reference-cache)). It is what finds the node's data on the server's views. | + +`_remote_access_context` passes the second as `server_node_ref`. It is `None` on a node that has +not registered yet and on one without the telemetry package, and the page then says it has not been +assigned rather than leaving the row blank. This is the same value the Home page's Telemetry card +shows. + +### Remote Support - `remote_host` is derived as `.`, not reported, so the page can name the address before anything has provisioned it. @@ -512,11 +599,11 @@ reachable, and on a network with more than one node it leads to the fleet list i - The two toggles drive separate endpoints, each reverting itself on failure, and are deliberately not linked: the point of two settings is that changing one says nothing about the other. -### How we reach you +### How We Reach You -The other half of Remote support: those settings are how support reaches the node, this is how +The other half of Remote Support: those settings are how support reaches the node, this is how support reaches the owner. It is its own section because it is the only block about a person rather -than the radar, and because the `name`/`desc`/`help` classes it used inside Remote support are only +than the radar, and because the `name`/`desc`/`help` classes it used inside Remote Support are only styled within a `toggle-row`, so it rendered as unstyled text there. `ContactFormConfig` is optional and nullable throughout, and empty is the steady state: a node with @@ -539,7 +626,7 @@ The length caps (`CONTACT_NAME_MAX_LENGTH` 64, `CONTACT_EMAIL_MAX_LENGTH` 255, them retina-telemetry cannot build the payload, so the whole document is refused and the owner is unreachable. They move only when the spec does. -### Node claim +### Node Claim **Not the contact email**, however alike the boxes look. The contact answers "whom do we ring about this node", is optional and grants nothing. The claim answers "who owns it": the server mails the diff --git a/docs/features/device-state-and-telemetry.md b/docs/features/device-state-and-telemetry.md index 1f38e20..7e525cc 100644 --- a/docs/features/device-state-and-telemetry.md +++ b/docs/features/device-state-and-telemetry.md @@ -152,7 +152,7 @@ The checkbox wording and this constant are a matched pair across a template and ## Contact details -`telemetry-contact.json` records whom to contact about the node. It is written by one route, `/set-up/contact`, used by both the wizard step and the How we reach you section on the configuration page, so the two cannot drift into storing different shapes. The shape mirrors the wire's `NodeContact`. +`telemetry-contact.json` records whom to contact about the node. It is written by one route, `/set-up/contact`, used by both the wizard step and the How We Reach You section on the configuration page, so the two cannot drift into storing different shapes. The shape mirrors the wire's `NodeContact`. - It is kept out of `telemetry-consent.json` on purpose. The consent records are versioned acceptances neither end may invent, and retina-telemetry refuses to register without all three. A mutable optional document sharing that file would let a malformed contact stop a node registering. The wire treats them as separate endpoints too. - Empty values are dropped rather than written as null, and a document with nothing left in it removes the file. The spec says a node with nothing to report never calls the endpoint, so no file is the honest way to say that. It is what an owner who skipped the step and an owner who cleared every box both mean, which is also why `get_telemetry_contact` returns `{}` rather than `None`: "never given" and "cleared" are the same downstream. Clearing is a real outcome rather than a no-op. @@ -239,7 +239,7 @@ The GUI therefore keeps a last-known-good copy in `telemetry-node-ref`: | Surface | What it shows | | --- | --- | | Home page Telemetry card (`index.html`) | Absent entirely when `read()` returns `None` (not a fault, so no card rather than an empty one). Otherwise the node reference (or "no identifier yet"), a status pill (Not running when stale, else the state), and either the stale message using `last_report` or `detail` verbatim. | -| Configuration page, Node claim section (`config.html`) | The stored claim address (`get_telemetry_claim`) alongside what the server has actually done with it (`claim` from the status document). The two disagree until the next heartbeat. When telemetry is not reporting the section says so rather than guessing. Send link is disabled once the node is owned. | +| Configuration page, Node Claim section (`config.html`) | The stored claim address (`get_telemetry_claim`) alongside what the server has actually done with it (`claim` from the status document). The two disagree until the next heartbeat. When telemetry is not reporting the section says so rather than guessing. Send link is disabled once the node is owned. | | Configuration page, contact section | `get_telemetry_contact`. | | Setup wizard (`routes/setup.py`) | Prefills the claim step from the stored address, or, on an owned node (a wizard re-run), from the owner's address in the status document, and then only lets the step be skipped. | | Fleet `/healthz` (`routes/fleet.py`) | `telemetry_payload`: just `node_ref`, `state` and `stale`. Local file reads only, because peers use `/healthz` to decide whether this node exists, and anything slow would make a busy node look absent. | diff --git a/docs/features/remote-access.md b/docs/features/remote-access.md index 66577b6..fa683f0 100644 --- a/docs/features/remote-access.md +++ b/docs/features/remote-access.md @@ -11,7 +11,7 @@ A node's GUI can be reached two ways: from its own network (the LAN pathway), an | [mender_connect.py](../../src/mender_connect.py) | `MenderConnect`: enforces the shell agreement by editing `mender-connect.conf` and restarting the daemon. | | [ssh_keys.py](../../src/ssh_keys.py) | `SSHKeyManager`: validates and stores public keys in the `authorized_keys` file sshd trusts. | | [routes/remote_access.py](../../src/routes/remote_access.py) | `/login`, `/logout`, and the `/remote-access/*` controls (toggle, shell, password, generate). | -| [routes/config.py](../../src/routes/config.py) | `/ssh-keys` add and delete routes, and the context processor that feeds the Remote support section of the config page. | +| [routes/config.py](../../src/routes/config.py) | `/ssh-keys` add and delete routes, and the context processor that feeds the Remote Support section of the config page. | | [app.py](../../src/app.py) | `_gate_the_remote_pathways` (the before-request gate), `_PathwaySessionInterface` (cookie flags per pathway), `_reapply_shell_agreement` and the startup `publish_marker` call. | | [services.py](../../src/services.py) | Builds the shared instances and defines `REMOTE_ACCESS_DOMAIN`. | | [login.html](../../templates/login.html) | Password prompt for the deferred owner-password design. | diff --git a/docs/features/setup-wizard.md b/docs/features/setup-wizard.md index 519c7b7..281f36d 100644 --- a/docs/features/setup-wizard.md +++ b/docs/features/setup-wizard.md @@ -197,7 +197,7 @@ Optional name, email, phone and a two-letter phone country, placed straight afte so it is asked while the owner is still answering questions about themselves rather than about the radar. Nothing downstream blocks on it. -- **Save and continue** posts to `/set-up/contact`, the same route the "How we reach you" +- **Save and continue** posts to `/set-up/contact`, the same route the "How We Reach You" section on the Configuration page uses, so the two surfaces cannot drift into storing different shapes. - **Skip** writes nothing and moves on. Skipping is an answer, not an abandonment, and anything @@ -222,7 +222,7 @@ the radar. Nothing downstream blocks on it. Asks for an email address to send a claim link to. Opening the link binds the node to the account behind that address, creating one if needed. -- Posts to `/set-up/claim`, the same route as the Node claim section on the Configuration page. +- Posts to `/set-up/claim`, the same route as the Node Claim section on the Configuration page. - An empty box is filled with the contact email from the previous step as a suggestion only. Nothing is sent until **Send link and continue** is pressed. - Send with an empty box is refused on the page ("Enter an email address, or skip this step"), diff --git a/src/routes/config.py b/src/routes/config.py index b7c204d..f892fe6 100644 --- a/src/routes/config.py +++ b/src/routes/config.py @@ -66,6 +66,9 @@ def _remote_access_context(): 'claim': device_state.get_telemetry_claim(), 'claim_state': (telemetry or {}).get('claim'), 'claim_reported': telemetry is not None and not (telemetry or {}).get('stale'), + # The server's name for this node, or None before it has registered. + # See docs/features/config-editor.md#identifiers + 'server_node_ref': (telemetry or {}).get('node_ref'), # The *enforced* shell state, read back from mender-connect's config, # not what we recorded. None when that config cannot be read. 'shell_enforced': mender_connect.is_shell_enabled(), @@ -160,7 +163,7 @@ def set_node_name(): """Rename this node. Only a label (fleet page, DNS-SD TXT record): nothing addresses the node - by it. See docs/features/config-editor.md#this-node. + by it. See docs/features/config-editor.md#identifiers. """ from app import node_name diff --git a/static/common.css b/static/common.css index 4c919f8..7b055f8 100644 --- a/static/common.css +++ b/static/common.css @@ -14,6 +14,7 @@ --surface-2: oklch(0.985 0.003 75); --line: oklch(0.93 0.005 75); --line-2: oklch(0.96 0.004 75); + --line-strong: oklch(0.86 0.006 75); --ink: oklch(0.20 0.012 260); --ink-2: oklch(0.42 0.012 260); @@ -893,10 +894,14 @@ h1, h2, h3, h4, h5, h6 { flex-direction: column; gap: 2px; } +/* Group titles carry a rule, as they do in the page (.cfg-group), so they + read as dividers and not as one more link in the list. */ .side h3 { + display: flex; align-items: center; gap: 10px; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; - color: var(--ink-3); font-weight: 600; margin: 14px 0 4px 10px; + color: var(--ink-3); font-weight: 600; margin: 18px 0 6px 10px; } +.side h3::after { content: ""; flex: 1; height: 1px; background: var(--line-strong); } .side h3:first-child { margin-top: 0; } .side a { display: flex; align-items: center; gap: 10px; @@ -920,9 +925,28 @@ h1, h2, h3, h4, h5, h6 { border-radius: var(--r-lg); overflow: hidden; box-shadow: var(--shadow-sm); + /* The banner is sticky, so a section scrolled to from the side nav would + otherwise stop with its heading underneath it. */ + scroll-margin-top: 68px; } .cfg-section + .cfg-section { margin-top: 12px; } +/* Group titles in the page: the side nav's headings again, each with a rule + running out to the right edge to mark where one group ends and the next + starts. See docs/features/config-editor.md#page-layout */ +.cfg-group { + display: flex; align-items: center; gap: 14px; + margin: 40px 0 14px; + font-size: 12.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--ink-2); +} +.cfg-group::after { content: ""; flex: 1; height: 1px; background: var(--line-strong); } +main > .cfg-group:first-of-type { margin-top: 0; } +/* A group's first section stops lower when scrolled to from the side nav, so + its group title stays in view above it. */ +.cfg-group + .cfg-section, +.cfg-group + form > .cfg-section:first-of-type { scroll-margin-top: 112px; } + .cfg-section-head { display: flex; align-items: center; gap: 14px; padding: 12px 18px; @@ -1038,7 +1062,7 @@ h1, h2, h3, h4, h5, h6 { .ssh-key { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -/* Manage-list row matching the form's current transmitter (see Tower section JS) */ +/* Manage-list row matching the form's current transmitter (see Cached Towers section JS) */ #towerManageList .ssh-item.selected { background: var(--accent-soft); border-color: var(--accent-edge); diff --git a/templates/config.html b/templates/config.html index 95ec138..ab098e6 100644 --- a/templates/config.html +++ b/templates/config.html @@ -82,26 +82,29 @@ {% block content %}
- {# ── Side nav ── #} + {# ── Side nav ── + Groups and order match the page. See docs/features/config-editor.md#page-layout #} {# ── Main ── #} @@ -119,9 +122,6 @@

Configuration

{% endif %} -
- - {% if config_errors %}
{# A form-level refusal (e.g. a calibration holding the SDR) has no @@ -131,6 +131,88 @@

Configuration

{% endif %} + {# ════ Node ════ #} + {# Group titles match the side nav's. + See docs/features/config-editor.md#page-layout #} +

Node

+ {# ── Identifiers ── + Outside #configForm: the name saves on its own rather than on Apply, + and the rest is read-only. + See docs/features/config-editor.md#identifiers #} +
+
+

Identifiers

+ What this node is called, the address that always reaches it, and the two IDs it is known by. +
+
+
+
+ + + Shown on the Nodes page when there is more than one node + on the network. Leave it empty to be listed by + identifier. This is only a label, so changing it never + affects how the node is reached. + +
+
+
+ + +
+
+
+
+
+
+ + + This node's own address. It comes from the hardware and + never changes, so it is the one to bookmark. owl.local is + shared by every node on the network and may reach any of them. + +
+
+
http://{{ node_id }}.local
+
+
+
+
+ + + Identifies this node on your own network. It comes from the + hardware and never changes: it is the name in the address + above, and what the node is listed by until you name it. + +
+
+
{{ node_id }}
+
+
+
+
+ + + Identifies this node on the Retina server, which assigns it + when the node registers. Use it to find this node's data on + the Retina dashboard, and quote it to support. + +
+
+ {# Absent until the node has registered, and on a node with + no telemetry package. See docs/features/config-editor.md#identifiers #} + {% if server_node_ref %} +
{{ server_node_ref }}
+ {% else %} +
Not assigned yet. One is assigned once this node registers.
+ {% endif %} +
+
+
+
+ {# ── Mode ── #}
@@ -154,12 +236,177 @@

Mode

- {# ── Tower ── #} + {# ── Node claim ── + Not the contact email: this address is mailed a link that binds the + node to its account, so nothing copies the contact email in here. + One button, every press asks for a link. + See docs/features/config-editor.md#node-claim #} +
+
+

Node Claim

+ Links this node to your account, so it appears as yours. +
+
+

+ We email the address below a link. Opening it claims this node for whoever + owns that address, so use one you own. Nothing here changes how the node + runs: an unclaimed node records and reports exactly as a claimed one does. +

+ +
+
+ + Reported by the node itself, about once a minute. +
+
+ {% if not claim_reported %} + + The telemetry service is not reporting, so we cannot say. + + {% elif not claim_state %} + Not reported yet. + {% elif claim_state.state == 'owned' %} + Claimed +
+ Owned by {{ claim_state.email }}. To hand it to somebody else, + release it from your dashboard: it cannot be done from here. +
+ {% elif claim_state.state == 'pending' %} + Link sent +
Waiting for {{ claim_state.email }} to open it.
+ {% elif claim_state.state == 'unclaimed' %} + Not claimed + {% if claim_state.email %} + {# The state a declined or expired link leaves: the address is + still on file and nothing more will arrive unless asked. #} +
+ No link is waiting. Press Send link for another one to + {{ claim_state.email }}. +
+ {% endif %} + {% else %} + {{ claim_state.state }} + {% endif %} + + {% if claim_state and claim_state.undeliverable %} +
+ The last email bounced, so that address cannot be reached. Correct it + below and send. Sending to the same address again will not help. +
+ {% endif %} +
+
+ +
+
+ + The address that will own this node. +
+
+ +
+
+ +
+
+ + {% if claim_state and claim_state.state == 'owned' %} + This node is claimed. Release it from your dashboard first. + {% else %} + Emails a link to the address above. Press it again if a link never + arrived or was turned down. Empty the box and send to remove the address. + {% endif %} + +
+
+ {# Disabled while owned: a resend is refused there, and a new + address would be offering somebody else's node. #} + + +
+
+
+
+ + {# ── Network ── #} +
+
+

Network

+ Connect or switch this node's WiFi network. +
+
+
+
+
Checking…
+
+
+
+
Checking…
+
+ +
+ + +
+ +
+ +
What are you trying to do?
+
+ + +
+ + + +
+ +
+ + +
+ + Enter network name manually + + + +
+ + + +
+
+
+ + {# ════ Radar ════ #} +

Radar

+ {# Apply changes submits this form: the Radar and ADS-B groups. + See docs/features/config-editor.md#page-layout #} + + + + {# ── Cached Towers ── #} {% set towers = towers_cache.towers if towers_cache else [] %} -
+
-

Tower

- Pick a broadcast tower from your setup wizard's search results to set the transmitter location and center frequency below. Add or remove towers from this cached list manually. +

Cached Towers

+ Pick a broadcast tower from your setup wizard's search results to fill in the Tower section below. Add or remove towers from this cached list manually.
@@ -209,18 +456,22 @@

Tower

- {# ── Location ── #} - {# Build field lookup for location #} + {# ── Tower ── + The location form model's fields, plus capture.fc. + See docs/features/config-editor.md#tower-section #} + {# Build field lookups for location and capture #} {% set loc = {} %} {% for f in location_fields %}{% set _ = loc.update({f.name: f}) %}{% endfor %} -
+ {% set cap = {} %} + {% for f in capture_fields %}{% set _ = cap.update({f.name: f}) %}{% endfor %} +
-

Location

- Receiver and transmitter coordinates used by the geometry solver. +

Tower

+ The broadcast tower this node listens to and where the receiver stands. The geometry solver uses both positions.
{# Hand-built, not render_field, so every input looks up its own - config_errors key. Keep all eight wired. + config_errors key. Keep all nine wired. See docs/features/config-editor.md#validation #} {% set rx_name_err = config_errors.get('location.rx_name') if config_errors else none %} {% set rx_lat_err = config_errors.get('location.rx_latitude') if config_errors else none %} @@ -230,12 +481,17 @@

Location

{% set tx_lat_err = config_errors.get('location.tx_latitude') if config_errors else none %} {% set tx_lon_err = config_errors.get('location.tx_longitude') if config_errors else none %} {% set tx_alt_err = config_errors.get('location.tx_altitude') if config_errors else none %} + {% set fc_err = config_errors.get('capture.fc') if config_errors else none %}
Receiver
-
{{ loc.rx_name.description if loc.rx_name else '' }}
+ {# Not edited here: the receiver is this node, so it goes by + the node's name. Posted so the radar config follows it. + See docs/features/config-editor.md#receiver-name #} +
This node's name. Change it under Identifiers.
- +
{{ node_name or node_id }}
+ {% if rx_name_err %}
{{ rx_name_err }}
{% endif %}
@@ -279,6 +535,18 @@

Location

{% if tx_name_err %}
{{ tx_name_err }}
{% endif %}
+ {# A capture setting shown with the tower it belongs to: still + posted and stored as capture.fc. + See docs/features/config-editor.md#tower-section #} + {% if cap.fc %} +
+
{{ cap.fc.description }}
+
+
Hz
+ {% if fc_err %}
{{ fc_err }}
{% endif %} +
+
+ {% endif %} {% if loc.tx_latitude %}
{{ loc.tx_latitude.description }}
@@ -379,16 +647,35 @@

Capture

- {% for field in capture_fields %} + {# Center frequency is in the Tower section, with its tower. #} + {% for field in capture_fields if field.name != 'fc' %} {{ render_field(field, 'capture') }} {% endfor %}
+ {# ── Tracking ── + Its own section, not part of Capture: this is what is done with the + detections afterwards, not how the signal is captured. + See docs/features/config-editor.md#sections-and-schema-models #} +
+
+

Tracking

+ How detections are turned into tracks after capture. +
+
+ {% for field in retina_tracker_fields %} + {{ render_field(field, 'retina_tracker') }} + {% endfor %} +
+
+ + {# ════ ADS-B ════ #} +

ADS-B

{# ── ADS-B truth ── #}
-

ADS-B truth

+

ADS-B Truth

Match radar detections against known aircraft to validate accuracy.
@@ -446,70 +733,13 @@

tar1090

- {# ── Retina Tracker ── #} -
-
-

Retina Tracker

- Tuning for the retina-tracker sidecar's track confirmation. -
-
- {% for field in retina_tracker_fields %} - {{ render_field(field, 'retina_tracker') }} - {% endfor %} -
-
- - {# ── SSH access ── #} - {# ── This node ── - Deliberately outside #configForm, which closes above: like the rest - of Administration, this saves on its own rather than on Apply. #} -
+ {# ════ Other ════ #} +

Other

+
-

This node

- What this node is called, and the address that always reaches it. -
-
-
-
- - - Shown on the Nodes page when there is more than one node - on the network. Leave it empty to be listed by - identifier. This is only a label, so changing it never - affects how the node is reached. - -
-
-
- - -
-
-
-
-
-
- - - This node's own address. It comes from the hardware and - never changes, so it is the one to bookmark. owl.local is - shared by every node on the network and may reach any of them. - -
-
-
http://{{ node_id }}.local
-
-
-
-
- -
-
-

SSH access

+

SSH Access

Public keys allowed to SSH into this node. To add SSH keys for use, please review the related GitHub file: owl-os/ssh_pub_keys/README.txt
@@ -548,10 +778,46 @@

SSH access

+ {# ── Setup wizard ── #} +
+
+

Setup Wizard

+ Re-run the first-boot wizard to reconfigure the node. +
+ +
+ + {# ════ Support ════ #} +

Support

+ {# ── Cloud services ── #} +
+
+

Cloud Services

+ This node's link to Offworld: software updates and registration. +
+
+
+ +
+
Enable Cloud Services
+
Software updates and registration. The Remote Support settings below need this too.
+
+
+
+
+ {# ── Remote support ── #} -
+
-

Remote support

+

Remote Support

What our support team may do with this node when they are not at your location. Both settings are yours to change at any time, and neither affects how the node works day to day.
@@ -611,9 +877,9 @@

Remote support

{# ── How we reach you ── #} {# Optional throughout; empty means the node never calls the contact endpoint. See docs/features/config-editor.md#how-we-reach-you #} -
+
-

How we reach you

+

How We Reach You

Never published, and only used to support you with your device.
@@ -684,198 +950,6 @@

How we reach you

- {# ── Node claim ── - Not the contact email: this address is mailed a link that binds the - node to its account, so nothing copies the contact email in here. - One button, every press asks for a link. - See docs/features/config-editor.md#node-claim #} -
-
-

Node claim

- Links this node to your account, so it appears as yours. -
-
-

- We email the address below a link. Opening it claims this node for whoever - owns that address, so use one you own. Nothing here changes how the node - runs: an unclaimed node records and reports exactly as a claimed one does. -

- -
-
- - Reported by the node itself, about once a minute. -
-
- {% if not claim_reported %} - - The telemetry service is not reporting, so we cannot say. - - {% elif not claim_state %} - Not reported yet. - {% elif claim_state.state == 'owned' %} - Claimed -
- Owned by {{ claim_state.email }}. To hand it to somebody else, - release it from your dashboard: it cannot be done from here. -
- {% elif claim_state.state == 'pending' %} - Link sent -
Waiting for {{ claim_state.email }} to open it.
- {% elif claim_state.state == 'unclaimed' %} - Not claimed - {% if claim_state.email %} - {# The state a declined or expired link leaves: the address is - still on file and nothing more will arrive unless asked. #} -
- No link is waiting. Press Send link for another one to - {{ claim_state.email }}. -
- {% endif %} - {% else %} - {{ claim_state.state }} - {% endif %} - - {% if claim_state and claim_state.undeliverable %} -
- The last email bounced, so that address cannot be reached. Correct it - below and send. Sending to the same address again will not help. -
- {% endif %} -
-
- -
-
- - The address that will own this node. -
-
- -
-
- -
-
- - {% if claim_state and claim_state.state == 'owned' %} - This node is claimed. Release it from your dashboard first. - {% else %} - Emails a link to the address above. Press it again if a link never - arrived or was turned down. Empty the box and send to remove the address. - {% endif %} - -
-
- {# Disabled while owned: a resend is refused there, and a new - address would be offering somebody else's node. #} - - -
-
-
-
- - {# ── Cloud services ── #} -
-
-

Cloud services

- This node's link to Offworld: software updates and registration. -
-
-
- -
-
Enable cloud services
-
Software updates and registration. The Remote support settings above need this too.
-
-
-
-
- - {# ── Setup wizard ── #} -
-
-

Setup wizard

- Re-run the first-boot wizard to reconfigure the node. -
- -
- - {# ── Network ── #} -
-
-

Network

- Connect or switch this node's WiFi network. -
-
-
-
-
Checking…
-
-
-
-
Checking…
-
- -
- - -
- -
- -
What are you trying to do?
-
- - -
- - - -
- -
- - -
- - Enter network name manually - - - -
- - - -
-
-
- {# ── Save bar (bottom of all sections) ── #} {% if retina_installed %}
@@ -944,7 +1018,7 @@
{% elif field.type == 'checkbox' %} -
+
{% else %} -
+
{% if field.description %}{{ field.description }}{% endif %} @@ -83,24 +85,27 @@
{# ── Side nav ── - Groups and order match the page. See docs/features/config-editor.md#page-layout #} -
{# ── Mode ── #} -
+

Mode

Switch the node between passive radar, spectrum analysis, and SDRconnect. @@ -330,7 +335,7 @@

Node Claim

{# ── Network ── #} -
+

Network

Connect or switch this node's WiFi network. @@ -403,7 +408,7 @@

Radar

{# ── Cached Towers ── #} {% set towers = towers_cache.towers if towers_cache else [] %} -
+

Cached Towers

Pick a broadcast tower from your setup wizard's search results to fill in the Tower section below. Add or remove towers from this cached list manually. @@ -464,7 +469,7 @@

Cached Towers

{% for f in location_fields %}{% set _ = loc.update({f.name: f}) %}{% endfor %} {% set cap = {} %} {% for f in capture_fields %}{% set _ = cap.update({f.name: f}) %}{% endfor %} -
+

Tower

The broadcast tower this node listens to and where the receiver stands. The geometry solver uses both positions. @@ -589,17 +594,17 @@

Capture

different questions and take very different amounts of time. Quick Calibrate is the same run the setup wizard performs - see RetinaCalibrate.QUICK_RUN. #} -
+
Automatically tune tower and gain until a confirmed aircraft track appears (~10 min). Current tower only: find the best gain, then check it holds without overloading. No aircraft needed (~4 min).
-
+
Live peak level relative to clipping. 0 dBFS = saturated.
-
+
ReferenceTuner A
@@ -648,8 +653,10 @@

Capture

{# Center frequency is in the Tower section, with its tower. #} + {% set manual_fields = ('device_gainReductionA', 'device_gainReductionB', 'device_lnaState', + 'device_dabNotch', 'device_rfNotch') %} {% for field in capture_fields if field.name != 'fc' %} - {{ render_field(field, 'capture') }} + {{ render_field(field, 'capture', tutorial='cfg-manual' if field.name in manual_fields else '') }} {% endfor %}
@@ -658,7 +665,7 @@

Capture

Its own section, not part of Capture: this is what is done with the detections afterwards, not how the signal is captured. See docs/features/config-editor.md#sections-and-schema-models #} -
+

Tracking

How detections are turned into tracks after capture. @@ -673,7 +680,7 @@

Tracking

{# ════ ADS-B ════ #}

ADS-B

{# ── ADS-B truth ── #} -
+

ADS-B Truth

Match radar detections against known aircraft to validate accuracy. @@ -689,7 +696,7 @@

ADS-B Truth

{# Build field lookup for tar1090 #} {% set t = {} %} {% for f in tar1090_fields %}{% set _ = t.update({f.name: f}) %}{% endfor %} -
+

tar1090

ADS-B viewer configuration. @@ -737,7 +744,7 @@

tar1090

{# ════ Other ════ #}

Other

-
+

SSH Access

Public keys allowed to SSH into this node. To add SSH keys for use, please review the related GitHub file: owl-os/ssh_pub_keys/README.txt @@ -792,6 +799,22 @@

Setup Wizard

+ {# ── Tutorial ── + The guided tour on its own, without re-running setup. + See docs/features/tutorial.md#starting-a-run #} +
+
+

Tutorial

+ A guided tour of the Summary, Home and Configuration pages. +
+ +
+ {# ════ Support ════ #}

Support

{# ── Cloud services ── #} @@ -959,7 +982,7 @@

How We Reach You

- +
{% endif %} @@ -1803,7 +1826,7 @@ (function() { // Page order: the first of these on screen is the one marked active. var sectionIds = ['identifiers','mode','claim','network','cached-towers','tower','capture','tracking', - 'truth','tar1090','ssh','wizard','cloud','remote','contact']; + 'truth','tar1090','ssh','wizard','tutorial','cloud','remote','contact']; var links = {}; sectionIds.forEach(function(id) { var a = document.querySelector('.side a[href="#' + id + '"]'); diff --git a/templates/index.html b/templates/index.html index 82cc1d0..78f231c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,7 +5,7 @@
-
+

Home

{% if rx_name %}
{{ rx_name }}
@@ -35,8 +35,10 @@
Welcome to OWL-OS
{% endif %} + {# data-tutorial names what the guided tutorial points at; a step's target + must keep its token. See docs/features/tutorial.md#targets #} {% if tx_name %} -
+
@@ -55,7 +57,7 @@
Welcome to OWL-OS
{# Absent when the telemetry package isn't installed — not a fault, so no card rather than an empty one. See telemetry_status.py. #} {% if telemetry %} -
+

Telemetry

@@ -144,7 +146,7 @@
Welcome to OWL-OS

Services

{% if retina_node_version %} - +
@@ -162,7 +164,7 @@
Welcome to OWL-OS
Live radar data.
- +
@@ -179,7 +181,7 @@
Welcome to OWL-OS
~10s rolling peak-hold view.
- + + {# The invitation to the guided tutorial; the buttons are in setup.html's + footer. See docs/features/tutorial.md#starting-a-run #} +
+
+ +
+
+
New here? Take the tutorial
+
A short guided tour of the Summary, Home and Configuration pages. We recommend it if this is your first time setting up a node.
+
+
+
diff --git a/templates/summary.html b/templates/summary.html index 3c308aa..0fb026f 100644 --- a/templates/summary.html +++ b/templates/summary.html @@ -69,11 +69,15 @@

Nodes on this network

+ {# Each data-tutorial wrapper on this page is a region the guided tutorial + lights up; a step's target must keep its token. + See docs/features/tutorial.md#targets #} +
{% for card in cards %} {# The whole card is the link, exactly as a banner tab is. A card that only looked clickable would be the odd one out on this page. #} - + +
{# Fixed links out. A separate section rather than more cards in the grid above: those stand for hardware on this network and this one does not, and mixing them would make the antenna mark stop meaning anything. #} +

Resources

@@ -167,10 +173,12 @@ {{ link_card(resource) }} {% endfor %}
+
{# Kept apart from Resources, and above the reading matter below it: the links above are things to look at, this is what to do when the node is not doing what it should. #} +

Help

If you are running into problems, please reach out.

@@ -179,10 +187,12 @@ {{ link_card(item) }} {% endfor %}
+
{# Progressive: the markup alone is a correct still diagram, and flight-sim.js reveals the hidden controls. Keep it that way. See docs/architecture.md#flight-simulator #} +

Doppler Mapping Tool

Your node measures two things: how much further the echo travelled than @@ -261,6 +271,7 @@ Drag across the sky to draw your own path. Grab the blue ring to aim the beam. The track breaks wherever the aircraft leaves it.

+
{% if primer_url %}

diff --git a/tests/test_config_layout.py b/tests/test_config_layout.py index d88bbcd..6b99913 100644 --- a/tests/test_config_layout.py +++ b/tests/test_config_layout.py @@ -17,7 +17,7 @@ ("Radar", [("cached-towers", "Cached Towers"), ("tower", "Tower"), ("capture", "Capture"), ("tracking", "Tracking")]), ("ADS-B", [("truth", "ADS-B Truth"), ("tar1090", "tar1090")]), - ("Other", [("ssh", "SSH Access"), ("wizard", "Setup Wizard")]), + ("Other", [("ssh", "SSH Access"), ("wizard", "Setup Wizard"), ("tutorial", "Tutorial")]), ("Support", [("cloud", "Cloud Services"), ("remote", "Remote Support"), ("contact", "How We Reach You")]), ] diff --git a/tests/test_tutorial.py b/tests/test_tutorial.py new file mode 100644 index 0000000..5654637 --- /dev/null +++ b/tests/test_tutorial.py @@ -0,0 +1,292 @@ +"""Tests for the guided tutorial. + +The tour is drawn in the browser, so what can be held here is everything the +browser is handed: where a run starts, which steps it has, and that every +step still has something on its page to point at. +""" + +import json +import re +import struct +from pathlib import Path + +import pytest + +import tutorial + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +SELF = "ret7a000001" # the node_id the app_client fixture writes +OTHER = "ret4a000001" + + +class FakePeers: + """Stands in for the mDNS PeerDirectory, which needs a LAN to be real.""" + + def __init__(self, *nodes): + self._nodes = list(nodes) + + def peers(self): + return self._nodes + + +def node(node_id, is_self=False): + return {"node_id": node_id, "friendly_name": "", "hostname": f"{node_id}.local", + "address": "192.0.2.10", "port": "80", "is_self": is_self, "healthz": None} + + +@pytest.fixture +def fleet(monkeypatch): + """Give the running app a peer directory the test controls.""" + import app as app_module + + def set_nodes(*nodes): + monkeypatch.setattr(app_module, "peers", FakePeers(*nodes)) + + set_nodes(node(SELF, is_self=True)) + return set_nodes + + +def two_nodes(fleet): + fleet(node(SELF, is_self=True), node(OTHER)) + + +def payload_in(body): + """The tour payload a page was rendered with, or None when it has none.""" + found = re.search(r"window\.OWL_TUTORIAL = (.*?);", body) + return json.loads(found.group(1)) if found else None + + +# ── The steps themselves ─────────────────────────────────────── + + +def test_every_step_says_a_sentence_or_two(): + """A card is a caption, not a manual.""" + for step in tutorial.STEPS: + body = step["body"] + assert body.endswith("."), step["title"] + assert 1 <= len(re.findall(r"[.!?](?:\s|$)", body)) <= 2, step["title"] + + +def test_the_pages_are_toured_in_order_and_the_tour_closes_on_summary(): + pages = [s["page"] for s in tutorial.STEPS] + assert pages[-1] == "/summary" + assert pages[:-1] == sorted(pages[:-1], key=["/summary", "/", "/config"].index) + assert [s["closing"] for s in tutorial.STEPS] == [False] * (len(pages) - 1) + [True] + + +def test_a_single_node_skips_summary_s_opening_steps_but_not_the_closing_one(): + steps = tutorial.steps_for(include_summary=False) + assert steps[0]["page"] == "/" + assert [s["title"] for s in steps if s["page"] == "/summary"] == ["That's everything"] + assert len(tutorial.steps_for(include_summary=True)) == len(tutorial.STEPS) + + +def test_the_closing_step_points_at_the_links_and_the_wiki(): + step = tutorial.STEPS[-1] + assert [t["name"] for t in step["targets"]] == ["summary-resources", "summary-help"] + assert step["link"]["url"] == "https://github.com/offworldlabs/owl-os/wiki" + + +def image_size(path): + """Width and height of a PNG or JPEG, read from its header.""" + data = path.read_bytes() + if data[:8] == b"\x89PNG\r\n\x1a\n": + return struct.unpack(">II", data[16:24]) # IHDR is always first + assert data[:2] == b"\xff\xd8", path + at = 2 + while at < len(data): + marker, length = data[at + 1], struct.unpack(">H", data[at + 2:at + 4])[0] + # The frame header (SOF0-SOF15, bar the three that are not frames). + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + height, width = struct.unpack(">HH", data[at + 5:at + 9]) + return width, height + at += 2 + length + raise AssertionError(f"no frame header in {path}") + + +def test_every_example_image_exists_at_the_size_the_step_declares(): + """The card is laid out from the declared size, before the image loads.""" + images = [s["image"] for s in tutorial.STEPS if s["image"]] + assert len({image["url"] for image in images}) == 6 + for image in images: + path = PROJECT_ROOT / image["url"].lstrip("/") + assert path.is_file(), image["url"] + assert image_size(path) == (image["width"], image["height"]), image["url"] + assert image["alt"] + + +def test_every_mark_is_on_its_image(): + """A mark is a point in the image's own pixels, so resizing the file moves it.""" + marked = [s["image"] for s in tutorial.STEPS if s["image"] and s["image"]["marks"]] + assert marked + for image in marked: + for mark in image["marks"]: + assert 0 <= mark["x"] <= image["width"] and 0 <= mark["y"] <= image["height"], image["url"] + + +def test_the_pages_are_joined_by_click_through_steps(): + """Summary hands over to Home, and Home to Config, by a click on the page. + + Each is the last step on its page, so the click always changes page. + """ + clicks = [i for i, s in enumerate(tutorial.STEPS) if s["click"]] + assert [tutorial.STEPS[i]["page"] for i in clicks] == ["/summary", "/"] + for i in clicks: + assert tutorial.STEPS[i + 1]["page"] != tutorial.STEPS[i]["page"] + + +def test_the_last_step_is_not_a_click_through(): + assert not tutorial.STEPS[-1]["click"] + + +def test_the_controller_step_links_to_the_wiki(): + step = next(s for s in tutorial.STEPS if s["title"] == "Controller") + assert step["link"]["url"].endswith("/wiki/3-Understanding-Node-Data") + + +# ── Starting a run ───────────────────────────────────────────── + + +def test_one_node_starts_on_home(app_client, fleet): + response = app_client.get("/tutorial") + assert response.status_code == 302 + assert response.headers["Location"] == "/?tutorial=1" + + +def test_several_nodes_start_on_summary(app_client, fleet): + two_nodes(fleet) + assert app_client.get("/tutorial").headers["Location"] == "/summary?tutorial=1" + + +def test_demo_tours_summary_on_a_single_node_and_stays_in_demo(app_client, fleet): + assert app_client.get("/tutorial?demo=1").headers["Location"] == "/summary?tutorial=1&demo=1" + body = app_client.get("/summary?tutorial=1&demo=1").data.decode() + run = payload_in(body) + assert all(step["url"].endswith("&demo=1") for step in run["steps"]) + assert run["exit_url"] == "/summary?demo=1" + + +def test_a_run_cannot_start_mid_wizard(app_client, fleet): + import app as app_module + + app_module.device_state.save_setup_wizard_step("location") + response = app_client.get("/tutorial") + assert response.headers["Location"] == "/set-up" + + +# ── What a page is handed ────────────────────────────────────── + + +def test_a_page_without_the_parameter_has_no_tour(app_client, fleet): + body = app_client.get("/").data.decode() + assert payload_in(body) is None + assert "tutorial.js" not in body + assert "tutorial.css" not in body + + +def test_a_step_page_carries_the_whole_run(app_client, fleet): + body = app_client.get("/?tutorial=1").data.decode() + run = payload_in(body) + assert run["current"] == 0 + assert [s["url"] for s in run["steps"][:2]] == ["/?tutorial=1", "/?tutorial=2"] + assert len(run["steps"]) == len(tutorial.steps_for(include_summary=False)) + assert run["exit_url"] == "/summary" + assert "/static/tutorial.js" in body + assert "/static/tutorial.css" in body + + +def test_step_numbers_follow_the_run_not_the_full_list(app_client, fleet): + """With Summary toured, Home's first step is no longer step 1.""" + two_nodes(fleet) + summary_steps = sum(1 for s in tutorial.STEPS if s["page"] == "/summary" and not s["closing"]) + assert payload_in(app_client.get("/?tutorial=1").data.decode()) is None + run = payload_in(app_client.get(f"/?tutorial={summary_steps + 1}").data.decode()) + assert run["current"] == summary_steps + assert run["steps"][summary_steps]["title"] == "Welcome to your node" + + +@pytest.mark.parametrize("url", [ + "/config?tutorial=1", # a Home step, asked of Config + "/?tutorial=0", + "/?tutorial=999", + "/?tutorial=banana", + "/?tutorial=", +]) +def test_a_link_that_names_no_step_here_renders_the_plain_page(app_client, fleet, url): + response = app_client.get(url) + assert response.status_code == 200 + assert payload_in(response.data.decode()) is None + + +def test_the_wizard_page_never_runs_the_tour(app_client, fleet): + assert payload_in(app_client.get("/set-up?tutorial=1").data.decode()) is None + + +# ── Every step has something to point at ─────────────────────── + + +def tokens_on(body): + found = set() + for value in re.findall(r'data-tutorial="([^"]+)"', body): + found.update(value.split()) + return found + + +def test_every_target_exists_on_its_page(app_client, fleet): + """A step whose target was renamed or removed would point at nothing. + + Home is fetched in demo mode because the tower row and telemetry card it + points at are only drawn once a node has a tower and a telemetry package. + """ + two_nodes(fleet) + pages = { + "/summary": tokens_on(app_client.get("/summary").data.decode()), + "/": tokens_on(app_client.get("/?demo=1").data.decode()), + "/config": tokens_on(app_client.get("/config").data.decode()), + } + for step in tutorial.STEPS: + for target in step["targets"]: + assert target["name"] in pages[step["page"]], (step["title"], target["name"]) + + +def test_no_token_is_left_without_a_step(app_client, fleet): + """The reverse: markup tagged for a step that no longer exists.""" + two_nodes(fleet) + used = {t["name"] for s in tutorial.STEPS for t in s["targets"]} + for url in ("/summary", "/?demo=1", "/config"): + assert tokens_on(app_client.get(url).data.decode()) <= used, url + + +# ── The two ways in ──────────────────────────────────────────── + + +def test_the_wizard_recommends_the_tour_on_its_last_step(app_client, fleet): + body = app_client.get("/set-up").data.decode() + complete = body.split('data-step="complete"')[1].split("