diff --git a/docs/README.md b/docs/README.md index 9056a6a..6d6ae29 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ shape of the app, then the feature doc for the area you are changing. | [architecture.md](architecture.md) | App startup, blueprints, sessions and CSRF, service status, the apply service, stack reconcile, the restart lock and its couplings to other repos, home and summary pages | | [features/auto-calibrate.md](features/auto-calibrate.md) | Auto-Calibrate: the tower, gain and LNA search, overload handling, wedge recovery, the wizard path | | [features/setup-wizard.md](features/setup-wizard.md) | The onboarding wizard, step by step | +| [features/tutorial.md](features/tutorial.md) | The guided tutorial of the Summary, Home and Configuration pages | | [features/device-state-and-telemetry.md](features/device-state-and-telemetry.md) | What the node persists about itself, telemetry consent and claim, the telemetry status document | | [features/remote-access.md](features/remote-access.md) | LAN vs tunnel access, identity and login, Mender Connect, SSH keys | | [features/fleet-and-naming.md](features/fleet-and-naming.md) | mDNS peer discovery, the fleet bar, node names | @@ -34,6 +35,7 @@ Use this to find the doc to update when you change a file. | `templates/base.html`, `templates/index.html`, `templates/summary.html`, `templates/eula.html`, `static/flight-sim.js`, `static/common.css` | [architecture.md](architecture.md) | | `src/calibrator.py`, `src/routes/calibrate.py`, `src/blah2_client.py`, `static/calibrate.js`, `templates/setup/_calibrate.html` | [features/auto-calibrate.md](features/auto-calibrate.md) | | `src/routes/setup.py`, `static/setup.js`, `templates/setup.html`, `templates/setup/*` | [features/setup-wizard.md](features/setup-wizard.md) | +| `src/tutorial.py`, `src/routes/tutorial.py`, `static/tutorial.js`, `static/tutorial.css`, `static/tutorial/*` | [features/tutorial.md](features/tutorial.md) | | `src/device_state.py`, `src/telemetry_status.py` | [features/device-state-and-telemetry.md](features/device-state-and-telemetry.md) | | `src/remote_access.py`, `src/access_identity.py`, `src/mender_connect.py`, `src/ssh_keys.py`, `src/routes/remote_access.py`, `templates/login.html`, `templates/remote_denied.html` | [features/remote-access.md](features/remote-access.md) | | `src/mdns_peers.py`, `src/routes/fleet.py`, `src/node_name.py`, `templates/_fleet_bar.html` | [features/fleet-and-naming.md](features/fleet-and-naming.md) | diff --git a/docs/architecture.md b/docs/architecture.md index fe08c96..4cbf180 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -179,6 +179,7 @@ Route modules are imported at the bottom of `app.py`, after the app exists, beca | `tracker`, `tracker_legacy` | [routes/tracker.py](../src/routes/tracker.py) | [tracker.md](features/tracker.md) | | `fleet` | [routes/fleet.py](../src/routes/fleet.py) | [fleet-and-naming.md](features/fleet-and-naming.md), [Summary page](#summary-page) | | `remote_access` | [routes/remote_access.py](../src/routes/remote_access.py) | [remote-access.md](features/remote-access.md) | +| `tutorial` | [routes/tutorial.py](../src/routes/tutorial.py) | [tutorial.md](features/tutorial.md) | `inject_globals` (a context processor) adds to every template: `node_id`, the owl-os and retina-node versions, `is_remote` and `pathway` (which way the request arrived, defaulting to @@ -186,6 +187,10 @@ LAN outside a request), and `fleet_nodes` for the banner. The banner is in `base node list has to come from here rather than per route. It is an in-memory list copied and sorted, cheap enough per render. +It also adds `tutorial`, which is `None` on every ordinary request and the guided tutorial's +payload when the address names a tour step that lives on the page being rendered. See +[tutorial.md](features/tutorial.md#how-a-run-moves). + ## Request hooks Flask runs app-level `before_request` handlers in registration order. The order in `app.py` @@ -518,8 +523,11 @@ redirects to `/set-up` while the setup wizard is in progress. Otherwise it shows the node identifier (`node_ref`) and state, and the `detail` prose from retina-telemetry verbatim: mapping its states to our own wording would be a second vocabulary to keep in step with a file we do not own. See [device-state-and-telemetry.md](features/device-state-and-telemetry.md). -- Service cards (Passive Radar, Max-Hold, Controller, ADS-B Map, Tracker), or the spectrum +- Service cards (Passive Radar, Max-Hold, ADS-B Map, Tracker, Controller), or the spectrum iframe or SDRconnect panel in those modes (see [sdr-mode.md](features/sdr-mode.md)). + Controller is last because it is the way into every view the other cards do not cover. The + Tracker card says how much it keeps (up to 4 hours, the sidecar's limit; see + [tracker.md](features/tracker.md#view-window)). The page is host-agnostic: `owl.local` and the node's own `ret.local` produce the same page. The shared alias is a way in, and the fleet banner moves you between nodes from there. @@ -556,14 +564,29 @@ blanks the node sub-nav: Home and Config would be about whichever node served th two sections cannot drift apart. They are separate from the node grid because those cards stand for hardware on this network. The outbound arrow means "this leaves for another page", so a `mailto` card does not wear one. -- **How your node sees**: the flight-path diagram below. +- **Doppler Mapping Tool**: the flight-path diagram below. ### Flight simulator -A cut-down version of the primer's closing simulation. The left panel shows a tower, the node, -the wedge of sky the node hears and a flight path; the right panel plots what the node would +A cut-down version of the primer's closing simulation. The top panel shows a tower, the node, +the wedge of sky the node hears and a flight path; the panel under it plots what the node would record, range (bistatic excess path) across and Doppler up. +The two panels are stacked, each the full width of the page's column, at every window size. +They used to sit side by side, which left each too small to draw a path in comfortably and cut +the beam off at the edges of the sky. The sky's `viewBox` in `summary.html` is sized from two +numbers in `flight-sim.js`, `BEAM_R` (how far the node hears) and `WIDEST` (the widest the beam +opens): pointing up, as it starts, the whole wedge is in the picture at its widest. The view is +1.3 times the box that would just hold that wedge, so the tower, the node and the beam sit in the +middle with sky to spare all round, and a path can start and end well outside the beam. Steered +far enough off vertical, a wide beam still runs off the bottom edge; fitting every direction would +need a square panel. `tests/test_fleet.py` holds the view to the two numbers, and the area a path +can be drawn in (`SKY`) to the view. + +Because the far arc of the wedge is now in the picture, it is also a real limit: an aircraft +beyond it is not heard (`inBeam`). Before, only the angle was checked, which did not show while +the arc was off the edge of the panel. + It is progressive. The markup alone is a correct still diagram, with the beam and a default flight path drawn in. [flight-sim.js](../static/flight-sim.js) only adds the ability to fly it, so the controls ship `hidden` and the script reveals them. With no script, no pointer events, or @@ -572,17 +595,21 @@ rather than inline so the browser caches it. How the numbers are made: -- Range is `d1 + d2 - baseline` scaled by `KM` (0.15 km per SVG unit, baseline about 27 km). +- Range is `d1 + d2 - baseline` scaled by `KM` (0.125 km per SVG unit: baseline about 22 km, and + the beam reaches 25 km). - Doppler uses the real bistatic relation, `-(v . (u1 + u2)) / lambda`, with a fixed 213 MHz centre frequency and 170 m/s aircraft speed. It is zero when the heading is perpendicular to the sum of the two unit legs, which is not the same as perpendicular to the node. That sum has magnitude at most 2, which caps Doppler at `2v/lambda`. -- The scale is chosen so the picture cannot reach its own axes: drawn into the furthest corner - the scene tops out around 51 km and 242 Hz, inside a plot of 60 km and 300 Hz. A track pinned +- The scale is chosen so the picture cannot reach its own axes: heard at the far end of the beam + the scene tops out around 50 km and 242 Hz, inside a plot of 60 km and 300 Hz. A track pinned to a bound would say the aircraft stopped changing when really the plot ran out. The clamps in the plotting code are a backstop, not a working part. - Leg lengths are floored so a path drawn straight over the tower or the node does not divide by zero and make the track vanish. +- The plot's box is written twice, in the markup and as `PLOT` in the script, as is the default + flight path (so the still diagram does not jump when the script takes over). Tests hold both + pairs together. - Where the aircraft is outside the beam, the track has a gap. Nothing was heard, so nothing is drawn; that break is the point of the beam. @@ -593,7 +620,15 @@ Behaviour: - The loop runs at most 30 fps and stops (not just hides) when the tab is hidden or the section scrolls out of view. - Drawn samples closer than 4 units are dropped; a path is a shape, not a recording of the hand. +- The aircraft's heading turns steadily between one drawn point and the next (`tangent`, `at`). + Read off each straight piece of the path, it jumps at every point and takes Doppler with it, + which drew the track as a sawtooth; small in the old half-width plot, obvious in this one. - The beam can be steered by dragging the ring or with the arrow keys, and narrowed or widened. + The ring is labelled "Grab to Aim". The label ships undrawn (`display="none"`) and the + script reveals it, for the same reason the controls ship hidden. +- The ring takes keyboard focus so the arrow keys work, but it never shows the browser's focus + outline: a mouse grab focuses it too, and the outline then sat round it as a black box. + Keyboard focus is shown by the ring thickening (`:focus-visible`). The plot axes carry their real limits, because a plot whose bounds are a mystery teaches the wrong lesson about where a track sits. The link to the full primer shows only when `PRIMER_URL` @@ -610,4 +645,9 @@ owner pathway; the LAN has no session to end. [common.css](../static/common.css) holds the shared design-system styles for every page. Its comments explain individual rules in place. +When `tutorial` is set, `base.html` also loads `tutorial.css`, the tour payload and +`tutorial.js`, which draws the guided tutorial over the page. Elements on the Summary, Home and +Configuration pages, in the fleet bar and on the sub-nav's Config link carry `data-tutorial` attributes naming what the tour +points at; they have no other effect. See [tutorial.md](features/tutorial.md#targets). + [eula.html](../templates/eula.html) is a placeholder, linked from the wizard. diff --git a/docs/features/config-editor.md b/docs/features/config-editor.md index 0f08194..6eaf785 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, Tutorial | 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,32 @@ 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` | | +| Tutorial | Other | link to `/tutorial` | Runs the guided tutorial alone. See [tutorial.md](tutorial.md). | +| 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 +572,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 +600,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 +627,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/fleet-and-naming.md b/docs/features/fleet-and-naming.md index b463347..9358d12 100644 --- a/docs/features/fleet-and-naming.md +++ b/docs/features/fleet-and-naming.md @@ -9,7 +9,7 @@ Every owl node on a LAN finds the others over mDNS (DNS-SD), and every page reti | [mdns_peers.py](../../src/mdns_peers.py) | `PeerDirectory`: runs `avahi-browse`, parses its output, probes each peer's `/healthz`, and holds the live peer list. Also `sort_key`, the fleet order. | | [routes/fleet.py](../../src/routes/fleet.py) | `/summary`, `/api/fleet/peers` and `/healthz`. Builds the banner tabs (`banner_nodes`) and Summary cards (`card_view`, `telemetry_view`). | | [node_name.py](../../src/node_name.py) | `NodeName`: reads, validates and writes the friendly name, then asks owl-os to re-advertise it. | -| [templates/_fleet_bar.html](../../templates/_fleet_bar.html) | The `fleet_bar` macro: brand block, Summary tab, one tab per node, outbound links. | +| [templates/_fleet_bar.html](../../templates/_fleet_bar.html) | The `fleet_bar` macro: brand block, Summary tab, one tab per node, the outbound link. | | [services.py](../../src/services.py) | Constructs the single `PeerDirectory` (`peers`) and `NodeName` (stored at `node-name` in the data directory). | | [routes/config.py](../../src/routes/config.py) | `POST /node-name`, the rename endpoint behind the name field on `/config`. | | owl-mdns-identity (owl-os) | Writes the `_owl-node._tcp` service file at boot and again on rename. | @@ -114,7 +114,8 @@ Display rules: | Tab tooltip | The node_id, plus the IPv4 address when known. | | Tab link | The node's absolute `http://ret.local/` URL (see [Node addresses](#node-addresses)). | | Active tab | This node's tab, unless the Summary tab is active. The page is always served by the node you are looking at, so there is nothing to track: the macro compares against the `node_id` passed in. | -| Outbound links | Retina Network Map and Retina Dashboard. Both leave the app, so both carry the outbound arrow. | +| Outbound link | Network Map and Dashboard (`app.retina.fm`). It leaves the app, so it carries the outbound arrow. | +| `data-tutorial` | Every tab carries `data-tutorial="fleet-tabs"`, and this node's tab also `fleet-this-tab`, which are what the guided tutorial lights up. On the tabs rather than the row so the row's markup is unchanged. See [tutorial.md](tutorial.md#targets). | `discovered_nodes` guarantees this node is in the list. Discovery takes a second or two to populate, and it can come back empty on a network that blocks multicast; neither is a reason to draw a banner with no tabs. If the peer list does not already include this node, a stand-in entry is added (hostname `.local`, no address, no friendly name) and the list is re-sorted rather than prepended, so the tab does not sit first for a second and then move. The banner and the Summary cards share this function deliberately: two answers to "which nodes are there" would disagree during exactly the seconds after boot when someone is most likely to be looking. @@ -148,8 +149,8 @@ For peers, the card's telemetry comes from the last good `/healthz` payload. For The Summary page also carries fixed outbound links, defined in `routes/fleet.py`: -- `RESOURCES`, ordered by distance from the node: the company site, the manual (owl-os wiki), then the two live views of the wider network (map and dashboard). -- `HELP`: the blah2 Discord and a support email address. The Discord is the blah2 project's own community server and is labelled as such. Calling it ours would send an owner with a hardware or account problem into a volunteer channel expecting Offworld Labs support, and land that community with questions it cannot answer. +- `RESOURCES`, ordered by distance from the node: the company site, the manual (owl-os wiki), the live view of the wider network (Network Map and Dashboard), then two outside ones: Passive Radar News, a site about the field (written as `passiveradar.com`, without the `www.` it redirects to, because the card shows the host as written), and the blah2 Discord, the community around the radar software the node runs. The network's map and dashboard used to be two sites with a link each; they are one now, at `app.retina.fm`, and the banner's button goes to the same address (`_fleet_bar.html` writes it out, and `tests/test_fleet.py` holds the two together). +- `HELP`: our own channels only, the Retina Discord and a support email address. The blah2 Discord is not here. It is the blah2 project's own community server, and listing it as help would send an owner with a hardware or account problem into a volunteer channel expecting Offworld Labs support, and land that community with questions it cannot answer; as a resource, named for whose it is, it reads as somewhere to learn more. The two Discord cards show the same host (`discord.gg`), so the names are what tell them apart. A Discord invite can be made to expire; the Retina one has to be a permanent invite, or the card on every node goes dead with it. - `BUY_URL`: where "Add another node" sends an owner with only one node. - `PRIMER_URL`: empty until the full primer is published. A dead link on an owner's node is worse than no link, so the template omits the line while this is blank; turning it on is one string. 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..a12b216 100644 --- a/docs/features/setup-wizard.md +++ b/docs/features/setup-wizard.md @@ -27,7 +27,8 @@ re-consent path, and most steps then become informational. | [src/device_state.py](../../src/device_state.py) | Wizard step file, completion flag, towers cache, consent/contact/claim records | Related docs: [auto-calibrate.md](auto-calibrate.md), [device-state-and-telemetry.md](device-state-and-telemetry.md), -[towers.md](towers.md), [ota-updates.md](ota-updates.md), [remote-access.md](remote-access.md). +[towers.md](towers.md), [ota-updates.md](ota-updates.md), [remote-access.md](remote-access.md), +[tutorial.md](tutorial.md). ## Step order @@ -197,7 +198,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 +223,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"), @@ -522,6 +523,12 @@ race and see spectrum mode while `enforce_radar_mode` is still running, then cal calibrate step saves its tuning and waits for the restart before letting the owner reach this step. +The step also invites the owner to the guided tutorial, saying it is recommended for anyone +setting up a node for the first time. The footer offers **Start tutorial** (a link to +`/tutorial`, the primary button) and **Go to dashboard**. Both are plain links, so neither +waits on `/set-up/complete`. The tour is covered in [tutorial.md](tutorial.md); in demo mode +the link carries `demo=1` so the tour runs in demo mode too. + ## Demo and dev modes | Mode | Trigger | Behaviour | diff --git a/docs/features/tutorial.md b/docs/features/tutorial.md new file mode 100644 index 0000000..da49d29 --- /dev/null +++ b/docs/features/tutorial.md @@ -0,0 +1,330 @@ +# Guided tutorial + +The tutorial is a tour of the pages an owner lands on after setup: Summary, Home and +Configuration. It runs over the real pages rather than over screenshots of them. One thing at a +time is lit while the rest of the page is dimmed, a card says in a sentence or two what it is and how +to use it, and an arrow joins the two. The card carries Previous, Next and Skip tutorial. To move +from one page to the next, the owner clicks the real control that goes there. + +It is look-only. It writes nothing to the node, posts nothing, and needs no CSRF token: every +request it makes is a GET for a page the owner could have opened by hand. + +## Where it lives + +| File | Role | +| --- | --- | +| [src/tutorial.py](../../src/tutorial.py) | `STEPS` (order, wording, targets, images), `steps_for`, `first_url`, `payload` | +| [src/routes/tutorial.py](../../src/routes/tutorial.py) | `/tutorial` (the entry point) and `tutorial_context` (the per-request payload) | +| [static/tutorial.js](../../static/tutorial.js) | Draws the overlay: dimming, lit regions, arrows, the card, keyboard handling | +| [static/tutorial.css](../../static/tutorial.css) | Overlay styles, loaded only while a step lives on the page | +| [static/tutorial/](../../static/tutorial/) | The example images shown on the service steps and the two Signal peak steps | +| [templates/base.html](../../templates/base.html) | Loads the stylesheet, payload and script when `tutorial` is set | +| [templates/setup/_complete.html](../../templates/setup/_complete.html), [templates/setup.html](../../templates/setup.html) | The invitation and Start tutorial button on the wizard's last step | +| [templates/config.html](../../templates/config.html) | The Tutorial section and its Launch tutorial button | + +## Starting a run + +There are two ways in, and both are a plain link to `/tutorial`: + +- The setup wizard's last step recommends the tour to anyone setting up a node for the first + time. Its footer offers **Start tutorial** as the primary button and **Go to dashboard** beside + it. See [setup-wizard.md](setup-wizard.md#completion). +- The Tutorial section of the Configuration page (in the Other group) has **Launch tutorial**, + which runs the tour alone without re-running setup. + +`/tutorial` redirects to the first step's page. While the setup wizard is in progress it +redirects to `/set-up` instead: Home and Config both bounce there until setup finishes, so a +tour started mid-wizard would lose its place on its first page change. While Auto-Calibrate is +running, the calibration hold in `app.py` sends `/tutorial` to `/config` like any other page, +which is the right answer: the tour can wait for the run. + +## How a run moves + +A step is addressed by its page and its number in the run: `/summary?tutorial=2`, +`/?tutorial=6`, `/config?tutorial=16`. There is no session state and nothing stored in the +browser, so a reload resumes on the step being shown and a copied link opens the same step. + +On every render, `inject_globals` in `app.py` calls `tutorial_context`. With no `tutorial` +parameter that is one dictionary lookup and the page is untouched. With one, `tutorial.payload` +returns the whole run (every step, with its URL) and which step is current, and `base.html` +hands that to the page as `window.OWL_TUTORIAL` along with `tutorial.js` and `tutorial.css`. + +The payload is `None`, and the plain page is rendered, unless the number names a step that lives +on the page being asked for. A stale or hand-edited link therefore shows the page rather than a +tour pointing at things that are not there. The wizard page never matches, so the tour cannot +appear over it. + +`tutorial.js` moves between steps on the same page without a reload and rewrites the address +with `history.replaceState`. When the next step is on another page it navigates to that step's +URL, and the server hands the new page the same run. The server owns the order and the wording; +the script only draws. + +Finish, on the last step, drops the tour and stays on Summary, whose links that step has just +pointed at. Skip tutorial (or Escape) closes the overlay where it +is and removes the parameter from the address, so a reload does not bring the tour back. + +## Which steps run + +`STEPS` lists every step in tour order: Summary, then Home, then Configuration, then one closing +step back on Summary. + +The Summary steps run only when there is more than one node. With a single node the Summary page +has one card and nothing to choose between, so the run starts on Home. `_include_summary` in +`routes/tutorial.py` asks `discovered_nodes`, the same list the banner and the Summary cards are +drawn from, so the tour cannot disagree with the page about how many nodes there are. + +The closing step is in every run (`closing=True`, which `steps_for` keeps whatever else it +drops). It is on Summary because that is where the Resources and Help links are, and Summary is +there to visit with one node as well as with several. So a single node's run starts on Home and +still ends on Summary. Getting there is an ordinary Next from the last Configuration step. + +Step numbers are positions in the run, not in `STEPS`. Home's first step is step 1 on a single +node and step 6 when Summary is toured. + +### Demo mode + +`/tutorial?demo=1` tours Summary even on a single node and carries `demo=1` on every step URL, +so Home is drawn with its demo tower and telemetry (see `routes/home.py`). It exists so the whole +tour can be reviewed on one node, including a development machine. The wizard's Start tutorial +button passes it on when the wizard itself is in demo mode. + +## Steps + +Each step has a page, a title, a body of a sentence or two, one or more targets, and optionally an +example image or a link out. The wording lives in `STEPS` and nowhere else. The last step on +Summary and on Home is a [click-through step](#click-through-steps). + +| Page | Step | Points at | +| --- | --- | --- | +| Summary | List of nodes | The node cards and the tabs in the banner | +| Summary | Resources | The Resources links | +| Summary | If you need help | The Help links | +| Summary | Doppler Mapping Tool | The flight-path simulator | +| Summary | Open your node (click-through) | This node's card and this node's tab | +| Home | Welcome to your node | The page heading with the site name, and this node's highlighted tab | +| Home | System information | The "Listening on" row and the Telemetry card | +| Home | Passive Radar: detections | Its service card, and white dots in the example image | +| Home | Passive Radar: expected flights | Its service card, and orange dots in the example image | +| Home | Passive Radar: do they line up? | Its service card, and a matched and an unmatched dot | +| Home | Max-Hold | Its service card, with an example image | +| Home | ADS-B Map | Its service card, with an example image | +| Home | Tracker | Its service card, with an example image | +| Home | Controller | Its service card, with a link to the wiki's Understanding Node Data page | +| Home | Open Configuration (click-through) | The Config link in the sub-nav | +| Configuration | Welcome to Configuration | The Config link in the sub-nav, the page heading and the side nav | +| Configuration | Apply your changes | The Apply changes button | +| Configuration | Mode | The Mode section and its side-nav link | +| Configuration | Wi-Fi network | The Network section and its side-nav link | +| Configuration | Cached Towers | The Cached Towers section and its side-nav link | +| Configuration | Tower | The Tower section and its side-nav link | +| Configuration | Capture: signal peak | The Signal peak meter, with an example of a good level | +| Configuration | Capture: overload | The Signal peak meter, with an example of the overload reading | +| Configuration | Capture: automatic calibration | The two calibrate buttons | +| Configuration | Capture: manual settings | The two gain reduction rows, the LNA state row and the two notch filter switches | +| Configuration | Tracking: Minimum SNR | The Tracking section and its side-nav link | +| Configuration | ADS-B settings | ADS-B Truth and tar1090, and their side-nav links | +| Configuration | SSH Access | The SSH Access section and its side-nav link | +| Summary | That's everything (closing) | The Resources and Help links again, with a link to the wiki | + +After the welcome and Apply steps, the Configuration steps follow the page's own order, group by +group (see [config-editor.md](config-editor.md#page-layout)), so the tour only ever moves down +the page. + +The Support group is not toured: most of it is set during setup, and nothing in it changes how +the node performs. + +The body is held to a sentence or two so the card stays a caption rather than a manual, and +`tests/test_tutorial.py` enforces it. Most are one. The Configuration welcome, the Minimum SNR step and the closing +step are two, because each has a second thing to say that is not about what is lit. + +The Configuration welcome says the tour skips the sections already completed during setup. That +is Node Claim, Cloud Services and How We Reach You, which the wizard asks for, and it is said so +an owner does not take a section the tour passes over for one that does not matter. + +### Tuning steps + +Capture gets four steps and Tracking a fifth because together they are the one piece of tuning +an owner is expected to do, and it needs an order: what to watch (the Signal peak meter), what +too far looks like, what to change (a calibration run, or the gain reduction, LNA and notch filter settings by +hand), and then Minimum SNR. The first two show an example of the meter, because on a node that +is not capturing, the meter on the page is empty. + +The Minimum SNR step says the default is 7 even when the box is blank. The box is blank +whenever the merged config carries no value, and the tracker then runs on its own default, +which is set in retina-node, not in this repository. If that default changes, the step's +wording has to change with it; nothing here can check it. + +The overload step shows what the page does when the radio overloads: the tuner's reading turns +to a red "overload" and the Overload help box appears under the meter (see +[config-editor.md](config-editor.md#overload-indicator)). Its picture is the page's own meter in that +state (the box is described, not shown: with it the picture was too tall for a small window), rendered on a development instance with simulated readings, because an overload cannot +be produced to order on a working node; a capture from a node that really is overloading can +replace it at any time. On a blah2 older than blah2-arm#79 an overload stops the radio instead, +and the meter reads "no signal"; the step describes the current behaviour and does not go into +that. + +Calibrating and changing the settings by hand are two steps, not one lighting both. The buttons +are at the top of the Capture section and the gain rows near the bottom, further apart than a +short window is tall, so as one step the rows were off the bottom of the screen on a laptop. The +manual step lights the notch filter switches along with the gain rows, since they move the +signal level as much as the gain does. + +The Apply step says "a setting on this page", which is simpler than what is true. Apply changes +submits the Radar and ADS-B groups only; the other sections each save on their own as soon as +they are changed (see [config-editor.md](config-editor.md#administration-sections)). The step +used to say "a radar setting" for that reason, and was reworded: +an owner who presses Apply after changing one of the others loses nothing, and "radar setting" +left them to work out which settings those were. + +Home's welcome step points at this node's tab and calls it the highlighted one. It does not say the +tab moves to the front, because it does not: tabs are in one fixed order on every node (see +[fleet-and-naming.md](fleet-and-naming.md#fleet-order)), and which node is open is shown by the +highlight alone. + +### Targets + +A target is a name. The region lit for it is the box around every visible element whose +`data-tutorial` attribute lists that name, so several elements can make up one region (the two +side-nav links for ADS-B Truth and tar1090) and one step can light several regions. + +Templates carry these attributes for the tutorial alone. They are deliberately not CSS classes or +ids borrowed from the page: a selector that happens to match today breaks silently when the page +is restyled, and the first anyone hears of it is a step pointing at nothing. With a dedicated +attribute, `test_every_target_exists_on_its_page` renders each page and fails when a step's +target is gone, and `test_no_token_is_left_without_a_step` fails when markup is still tagged for +a step that no longer exists. + +On the Summary page the attribute is on a wrapper `div` around each group, and in the banner it +is on each tab rather than on the row. Both keep the existing markup byte-for-byte, which the +fleet tests match as strings. + +On the Configuration page most fields are drawn by the `render_field` macro, which takes a +`tutorial` argument to put the attribute on a field's row. The manual step's five rows (three inputs and two switches) are named that +way. + +A target can opt out of its arrow (`_target(name, arrow=False)`). The Configuration steps use +this for the sections themselves: the side-nav link gets the arrow, and the section it leads to +is lit without a second arrow crossing the form. + +### Click-through steps + +The tour does not carry the owner from page to page on Next. The last Summary step asks them to +click this node's card or its tab, and the last Home step asks them to click Config, so the two +moves they will make most often are ones they have made once already. A step marked +`click=True` in `STEPS` has no Next button; its lit regions take the click instead, and the +pointer changes over them to say so. + +- **Only this node is lit on Summary.** With several nodes the other cards and tabs stay dimmed + and do nothing. Another node may be running a build without the tour, and the page that + follows has to be this node's Home. +- **The click does not follow the link.** The overlay still takes the click and sends the + browser to the next step's own URL. A node's card and tab link to `http://ret.local/` + (see [fleet-and-naming.md](fleet-and-naming.md#node-addresses)), which would drop the + `tutorial` parameter and, for someone who arrived by IP address, by the tunnel hostname or on + a development machine, move them to a host that may not resolve. Staying on the origin that is + serving the tour is what keeps it running everywhere. +- **Nothing else becomes clickable.** Everything outside the lit regions stays inert, as on + every other step. +- **The click is the only way on.** There is no Next and the right arrow key does nothing on + these steps, because the point is that the owner makes the move themselves. Previous and Skip + tutorial still work, so the step cannot trap anyone. +- Each click-through step is the last step on its page (`test_the_pages_are_joined_by_click_through_steps`), + so the click always changes page. + +### Example images + +The service steps show a picture of what the button opens, and two of the +[tuning steps](#tuning-steps) show the Signal peak meter. All but one are real captures (the exception is the overload picture, see +[Tuning steps](#tuning-steps)), supplied by +the team, and are re-encoded from their pixels when added so no capture metadata travels with the +file. `STEPS` declares each image's width and height, which the card uses to reserve the space +before the image loads; the test suite checks they match the files. + +A step with a picture uses a wide card (`.tut-wide`), because the picture has to be large enough +to read. No margin can hold a card that wide, so on these steps the card stands over the page, +wherever it covers least of what is lit. When it fits neither above nor below the button it +describes, it narrows to the space beside it rather than cover the button, down to a floor. + +Passive Radar takes three steps over one picture, because the display needs explaining rather +than naming: what the white dots are, what the orange dots are, and what it means when they do +not line up. Each step points at dots in the picture. An image's `marks` are points in the +image's own pixels, and `drawMarks` in `tutorial.js` draws a ring and an arrow on each in an SVG +laid over the image with the image's size as its `viewBox`, so a mark stays on its dot whatever +size the picture is shown at. Resizing or replacing the file means measuring the marks again; +`test_every_mark_is_on_its_image` only catches one that has fallen off the edge. + +The four service captures are JPEGs, stored at about twice the size they are shown so they stay +sharp on a high-density screen. Three of them are fields of noise, which PNG stores at several +times the size for no visible gain. The two Signal peak captures are the opposite case, flat +colour and small text, so they are PNGs, at the size they were captured. + +This repository is public. The radar, Max-Hold and Tracker displays plot range against Doppler +and carry no location, and the Signal peak captures show only the meter. The ADS-B map is the one example image that can show where a node is, so +anything that replaces it should be chosen with that in mind. + +## The overlay + +`tutorial.js` appends one fixed, full-window element above everything else on the page. It holds +an SVG and the card. + +- **Dimming and lit regions.** The SVG is a dim rectangle with a mask; each lit region is a + rounded hole in the mask with a bright, slowly pulsing ring around it. A mask rather than one + path with holes, because two regions can overlap (a side-nav link sits inside the side nav) and + overlapping holes in a single path cancel out. +- **The highlight stays on its target.** A region extends a few pixels past its elements so the + ring does not sit on their edge, but never further than half the gap to a neighbouring element + (`paddingFor`), so lighting one side-nav link or one button does not spill onto the next. +- **Measured against the overlay, not the window.** Every position is taken relative to the + overlay's own size. `window.innerWidth` includes the scrollbar and page coordinates do not, so + using it scales the whole drawing by a percent or so and the highlight drifts off its target + towards the right and bottom edges. Browsers that draw overlay scrollbars hide this, which is + how it got past the first round of testing; test with classic scrollbars. +- **Nothing underneath is clickable.** The overlay takes every click, including on the lit + regions. The tour points at Restart services, the mode switch and Apply changes, and an owner + reading a caption should not be able to trigger them by reaching for the page. The two + [click-through steps](#click-through-steps) are the exception, and even there the overlay + handles the click itself. +- **Card placement.** Except on steps with a picture, the card stands in the empty margin beside the page's column, centred on + the region it describes and on whichever side is nearer to it, so nothing on the page itself is + covered. `margins` measures the space either side of `.shell` (Configuration) or `main.main` + (Summary and Home), and the card narrows to fit it, down to a floor below which it would be + unreadable. Only when the window is too narrow for that (a small laptop on the Configuration + page, a phone anywhere) does `placeCard` fall back to standing the card over the page: it tries + the spots beside the region, then a grid across the window, and takes whichever covers the + least of what is lit. +- **Scrolling.** On each step the page scrolls to bring the lit regions into view: centred when + they fit, top-aligned under the banner when they are taller than the window. In the narrow + fallback it also leaves room for the card: region then card when both fit, the region at the + bottom when only it fits. Regions that stay put while the page scrolls (the banner, the + Configuration side nav, the save bar) are ignored when deciding where to scroll. The page can + still be scrolled by hand during a step. +- **The card waits for the scroll.** Moving to a step on the same page glides the page there. + While it glides the card and arrows are hidden (`holdUntilStill`: the `tut-moving` class, until + no scroll event has arrived for 140 ms), then fade in where they belong. Laid out mid-glide + they were drawn for wherever the region happened to be and jumped when it stopped, and a + picture card, which stands over the page instead of in the margin, could land somewhere else + entirely for a few frames. The rings are not hidden: they only follow their region. +- **Arrows.** One per region that wants one. An arrow ends at the middle of whichever edge of the + region faces the card, never at a corner, and starts from the card's facing edge as nearly + opposite that point as the card allows, so with the card level with its region the arrow runs + straight across. An arrow shorter than a couple of dozen pixels is left out as clutter. +- **A target that is not there.** Some targets depend on the node's state: the Services cards + are replaced in Spectrum and SDRconnect modes, and the "Listening on" row needs a chosen + tower. When a step has nothing on the page to light, the card is centred, everything is + dimmed, and the card says so rather than pointing at empty space. +- **Keyboard.** Right arrow is Next (except on a click-through step), left arrow is Previous, + Escape is Skip tutorial, and Tab cycles within the card. Focus moves to Next on every step, or to the card itself on a + click-through step, which has no Next. +- **Keeping up with the page.** The layout is redone on scroll, on resize, and whenever the + page's height changes (a `ResizeObserver` on `body`), because both pages fill in status rows + after load and that moves the regions with no scroll or resize event. + +Motion is limited to the ring's pulse and smooth scrolling, and both are switched off under +`prefers-reduced-motion`. + +## Changing the tour + +- To reword, reorder, add or remove a step, edit `STEPS` in `src/tutorial.py`. +- To point at something new, add `data-tutorial=""` to the element in its template and + name it in the step's targets. +- Update the table under [Steps](#steps), and run `pytest tests/test_tutorial.py`. diff --git a/src/app.py b/src/app.py index 1bdc4e9..0838482 100644 --- a/src/app.py +++ b/src/app.py @@ -229,6 +229,7 @@ def inject_globals(): # Imported here rather than at module scope: the route modules are # deliberately imported at the bottom of this file, after the app exists. from routes.fleet import banner_nodes + from routes.tutorial import tutorial_context owl_os_version, retina_node_version = mender.get_versions() @@ -245,6 +246,8 @@ def inject_globals(): 'retina_node_version': retina_node_version, # The banner is in base.html, so the node list comes from here. 'fleet_nodes': banner_nodes(), + # None except on a page a tour step lives on. See docs/features/tutorial.md + 'tutorial': tutorial_context(), } @@ -261,6 +264,7 @@ def inject_globals(): from routes.towers import bp as towers_bp from routes.tracker import bp as tracker_bp from routes.tracker import legacy_bp as tracker_legacy_bp +from routes.tutorial import bp as tutorial_bp app.register_blueprint(home_bp) app.register_blueprint(config_bp) @@ -274,6 +278,7 @@ def inject_globals(): app.register_blueprint(tracker_legacy_bp) app.register_blueprint(fleet_bp) app.register_blueprint(remote_access_bp) +app.register_blueprint(tutorial_bp) # Reachable on the owner pathway without a session: the login page itself, the 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/src/routes/fleet.py b/src/routes/fleet.py index fd1e8e6..c9f320a 100644 --- a/src/routes/fleet.py +++ b/src/routes/fleet.py @@ -41,21 +41,27 @@ def _mailto(name, address): PRIMER_URL = "" # Fixed links out, ordered by distance from the node: the company, the manual -# for this box, then the two live views of the wider network. +# for this box, the live view of the wider network (dashboard and map are one +# site, also linked from the banner), news from the field, then the community +# around blah2, the radar software the node runs. That Discord is theirs, not +# Offworld Labs support, which is why it is here and not under Help. +# Each card shows its URL's host, so a URL is written the way it should read: +# passiveradar.com, not the www. address it redirects to. RESOURCES = ( _resource("Offworld Labs", "https://offworldlabs.com", "globe"), _resource("Retina Wiki", "https://github.com/offworldlabs/owl-os/wiki/4-Troubleshooting-and-Tuning", "book"), - _resource("Retina Network Map", "https://map.retina.fm", "map"), - _resource("Retina Dashboard", "https://dash.retina.fm", "chart"), + _resource("Network Map and Dashboard", "https://app.retina.fm", "map"), + _resource("Passive Radar News", "https://passiveradar.com/", "news"), + _resource("blah2 Discord", "https://discord.gg/ewNQbeK5Zn", "chat"), ) -# Where to go when something is wrong. The Discord is the blah2 project's -# community server, not Offworld Labs support, and is labelled as such. +# Where to go when something is wrong: our own channels only, the Retina +# Discord and the support address. # See docs/features/fleet-and-naming.md#resources-and-help-links. HELP = ( - _resource("blah2 Discord", "https://discord.gg/ewNQbeK5Zn", "chat"), + _resource("Retina Discord", "https://discord.gg/tZHwSm3Rs", "chat"), _mailto("Email us", "info@offworldlabs.com"), ) diff --git a/src/routes/tutorial.py b/src/routes/tutorial.py new file mode 100644 index 0000000..466b76f --- /dev/null +++ b/src/routes/tutorial.py @@ -0,0 +1,50 @@ +"""The guided tutorial's entry point and per-request context. + +The tour itself is drawn by static/tutorial.js over the real pages; this +module only decides where it starts and which steps a run has. +See docs/features/tutorial.md. +""" + +from flask import Blueprint, redirect, request + +import tutorial + +bp = Blueprint("tutorial", __name__) + + +def _demo(): + return request.args.get("demo") == "1" + + +def _include_summary(): + """Whether this run tours the Summary page. + + Only with more than one node, or in demo mode so the whole tour can be + reviewed on a single node. See docs/features/tutorial.md#which-steps-run. + """ + from routes.fleet import discovered_nodes + + return _demo() or len(discovered_nodes()) > 1 + + +@bp.route("/tutorial") +def start(): + """Send the browser to the first step. + + See docs/features/tutorial.md#starting-a-run. + """ + from app import device_state + + # Home and Config both bounce to the wizard while it is in progress, so a + # tour started then would lose its place on the first page change. + if device_state.is_setup_wizard_in_progress(): + return redirect("/set-up") + return redirect(tutorial.first_url(_include_summary(), _demo())) + + +def tutorial_context(): + """The tour payload for this request's page, or None when no tour is running.""" + step_arg = request.args.get("tutorial") + if step_arg is None: + return None + return tutorial.payload(request.path, step_arg, _include_summary(), _demo()) diff --git a/src/tutorial.py b/src/tutorial.py new file mode 100644 index 0000000..fc99c2b --- /dev/null +++ b/src/tutorial.py @@ -0,0 +1,261 @@ +"""The guided tutorial: what it points at, in what order, and what it says. + +Pure data and URL building, no Flask. See docs/features/tutorial.md. +""" + +SUMMARY, HOME, CONFIG = "/summary", "/", "/config" + +# Where the Controller step sends an owner who wants every view explained. +NODE_DATA_WIKI_URL = "https://github.com/offworldlabs/owl-os/wiki/3-Understanding-Node-Data" +# Where the closing step sends an owner who wants to read more. +WIKI_URL = "https://github.com/offworldlabs/owl-os/wiki" + + +def _target(name, arrow=True): + """One highlighted region: every element whose data-tutorial lists `name`.""" + return {"name": name, "arrow": arrow} + + +def _image(name, width, height, alt, marks=()): + """An example image. The size is the file's own, so the card can be laid + out before the image arrives; tests/test_tutorial.py checks it matches. + + `marks` are (x, y) points in the image's own pixels that the step points + at. See docs/features/tutorial.md#example-images. + """ + return {"url": f"/static/tutorial/{name}", "width": width, "height": height, "alt": alt, + "marks": [{"x": x, "y": y} for x, y in marks]} + + +def _radar(marks): + """The Passive Radar capture, pointing at `marks`. Shared by its three steps.""" + return _image("passive-radar.jpg", 1328, 631, + "The Passive Radar display: white and orange dots on a delay-Doppler map", + marks) + + +def _step(page, title, body, targets, image=None, link=None, click=False, closing=False): + """One step. `click` makes it a hand-over: the owner clicks the lit thing + itself to go on. See docs/features/tutorial.md#click-through-steps. + `closing` marks the step every run ends on, whatever it toured. + See docs/features/tutorial.md#which-steps-run.""" + return { + "page": page, + "title": title, + "body": body, + "targets": [t if isinstance(t, dict) else _target(t) for t in targets], + "image": image, + "link": link, + "click": click, + "closing": closing, + } + + +# Order is the tour. Each body is a sentence or two, and each target name must exist +# as a data-tutorial token on its page: tests/test_tutorial.py checks both. +# See docs/features/tutorial.md#steps. +STEPS = ( + # ── Summary: only toured when there is more than one node ── + _step(SUMMARY, "List of nodes", + "Every node on your network is listed here and in the tabs at the top, " + "and clicking either one opens that node.", + ["summary-nodes", "fleet-tabs"]), + _step(SUMMARY, "Resources", + "These links open the Offworld Labs site, the Retina Wiki, the Network " + "Map and Dashboard, Passive Radar News and the blah2 community Discord.", + ["summary-resources"]), + _step(SUMMARY, "If you need help", + "When something is not working, ask on the Retina Discord or email us " + "from here.", + ["summary-help"]), + _step(SUMMARY, "Doppler Mapping Tool", + "Draw a flight path across the sky and press Play to get a feel for the " + "range and Doppler your node records as an aircraft passes.", + ["summary-sim"]), + _step(SUMMARY, "Open your node", + "Click this node's card or its tab in the bar at the top to continue to " + "its Home page.", + ["summary-this-node", "fleet-this-tab"], click=True), + + # ── Home ── + # Says "highlighted", never "first": tabs keep one fixed order on every + # node. See docs/features/fleet-and-naming.md#fleet-order. + _step(HOME, "Welcome to your node", + "Welcome to this node's Home page, headed with the name of its site, " + "and the highlighted tab at the top always shows which node you are " + "looking at.", + ["home-head", "fleet-this-tab"]), + _step(HOME, "System information", + "Listening on names the broadcast tower this node is tuned to, and " + "Telemetry shows whether it is streaming and the identifier you use to " + "find its data.", + ["home-system"]), + # Passive Radar takes three steps on one picture, each pointing at dots in it. + _step(HOME, "Passive Radar: detections", + "Opens the live radar display, where each white dot is an object your " + "node has detected.", + ["svc-radar"], + image=_radar([(983, 513), (1189, 363)])), + _step(HOME, "Passive Radar: expected flights", + "Each orange dot is where a flight is expected to appear, worked out " + "from its ADS-B position.", + ["svc-radar"], + image=_radar([(591, 173), (481, 459)])), + _step(HOME, "Passive Radar: do they line up?", + "The goal is for the white and orange dots to line up, but a miss does " + "not always mean a problem, because the antenna's cone may not face " + "that aircraft.", + ["svc-radar"], + image=_radar([(660, 280), (1002, 232)])), + _step(HOME, "Max-Hold", + "Opens the same display holding the previous 10 seconds of detection " + "data, so a moving object leaves a trail.", + ["svc-maxhold"], + image=_image("max-hold.jpg", 1257, 570, + "The Max-Hold display: the same map with echoes drawn out into trails")), + _step(HOME, "ADS-B Map", + "Opens a live map of nearby aircraft, best read with Max-Hold open " + "beside it while you think about which way your antenna is facing.", + ["svc-adsb"], + image=_image("adsb-map.jpg", 1045, 666, + "The ADS-B Map: aircraft drawn on a map around the node")), + _step(HOME, "Tracker", + "Opens the Tracker, which keeps up to 4 hours of data and lets you sort " + "by detections not associated with a track, tracks below the SNR " + "threshold, ADS-B verified tracks, and more.", + ["svc-tracker"], + image=_image("tracker.jpg", 1328, 547, + "The Tracker: tracks drawn across a delay-Doppler plot, with its filters")), + _step(HOME, "Controller", + "Opens the full list of radar views for a few more specialised " + "displays, and the wiki explains what each one shows.", + ["svc-controller"], + link={"label": "Understanding Node Data", "url": NODE_DATA_WIKI_URL}), + _step(HOME, "Open Configuration", + "Click Config in the bar under the node tabs to continue to the " + "Configuration page.", + ["subnav-config"], click=True), + + # ── Configuration ── + # After the welcome and Apply steps these follow the page from top to bottom. + _step(CONFIG, "Welcome to Configuration", + "Here you tune the radar and manage the node, with every section listed " + "in the menu on the left. The tour skips the sections you already " + "completed during setup, but they can all be found here.", + ["subnav-config", _target("cfg-head", arrow=False), "cfg-side"]), + # Says "a setting on this page" though only the Radar and ADS-B groups + # need Apply. See docs/features/tutorial.md#steps. + _step(CONFIG, "Apply your changes", + "When you change a setting on this page, press Apply changes before " + "you leave, and allow 30 seconds to a minute for the change to take " + "effect.", + ["cfg-apply"]), + _step(CONFIG, "Mode", + "Switch the node between Radar, a live Spectrum analyser and SDRconnect " + "for a desktop SDR app; the radar pauses in the other two.", + ["cfg-mode-nav", _target("cfg-mode", arrow=False)]), + _step(CONFIG, "Wi-Fi network", + "Shows the Ethernet and Wi-Fi connection and is where you choose the " + "Wi-Fi network this node joins.", + ["cfg-network-nav", _target("cfg-network", arrow=False)]), + _step(CONFIG, "Cached Towers", + "Lists the broadcast towers found near you during setup, where picking " + "one fills in the Tower section below and you can add or remove towers " + "by hand.", + ["cfg-cached-nav", _target("cfg-cached", arrow=False)]), + _step(CONFIG, "Tower", + "Holds the tower the radar listens to, with its position and center " + "frequency, alongside where your receiver stands.", + ["cfg-tower-nav", _target("cfg-tower", arrow=False)]), + # Capture takes four steps and Tracking a fifth: together they are how an + # owner tunes the node. See docs/features/tutorial.md#tuning-steps. + _step(CONFIG, "Capture: signal peak", + "Signal peak shows how strongly each tuner is receiving the tower, in " + "dBFS, and you want it as high as you can get it without overloading " + "it.", + ["cfg-peak", _target("cfg-capture-nav", arrow=False)], + image=_image("signal-peak.png", 786, 169, + "The Signal peak meter with both tuners in the green and amber, near -9 dBFS")), + _step(CONFIG, "Capture: overload", + "Push the signal too high and the radio overloads: the reading turns " + "to a red overload, and a box under the meter offers Quick Calibrate " + "or the safest settings.", + ["cfg-peak", _target("cfg-capture-nav", arrow=False)], + image=_image("signal-overload.png", 774, 167, + "The Signal peak meter with one tuner reading overload in red")), + # Two steps, not one: the buttons and the gain rows are too far apart to + # both be on screen in a short window. + _step(CONFIG, "Capture: automatic calibration", + "The easiest way to get there is an automatic calibration: Quick " + "Calibrate finds the best gain for your current tower, and " + "Auto-Calibrate also tries other towers, then waits for a flight " + "overhead and tunes against it.", + ["cfg-calibrate", _target("cfg-capture-nav", arrow=False)]), + _step(CONFIG, "Capture: manual settings", + "Or experiment by hand with the Reference and Surveillance Gain " + "Reduction settings, the Low Noise Amplifier State and the DAB and RF " + "notch filters, applying each change and watching Signal peak.", + ["cfg-manual", _target("cfg-capture-nav", arrow=False)]), + _step(CONFIG, "Tracking: Minimum SNR", + "Experiment with Minimum SNR too, which sets how strong a detection " + "must be before the tracker will use it. The default is 7, even when " + "the box is blank.", + ["cfg-tracking-nav", _target("cfg-tracking", arrow=False)]), + _step(CONFIG, "ADS-B settings", + "ADS-B Truth and tar1090 choose where aircraft positions come from, so " + "radar detections can be matched against known flights.", + ["cfg-adsb-nav", _target("cfg-adsb", arrow=False)]), + _step(CONFIG, "SSH Access", + "Lists the public keys allowed to log in to this node from a terminal, " + "and is where you add your own.", + ["cfg-ssh-nav", _target("cfg-ssh", arrow=False)]), + + # ── Back on Summary to close ── + # Every run ends here, a single node's too: it is where the links are. + _step(SUMMARY, "That's everything", + "That's everything! To learn more, visit our GitHub wiki, and don't " + "forget to reach out to us if you have issues or more questions.", + ["summary-resources", "summary-help"], + link={"label": "Retina Wiki", "url": WIKI_URL}, closing=True), +) + + +def steps_for(include_summary): + """The steps of one run: everything, or everything but Summary's opening + steps. The closing step, also on Summary, is in every run.""" + return [s for s in STEPS if include_summary or s["page"] != SUMMARY or s["closing"]] + + +def step_url(page, number, demo=False): + """Where step `number` (1-based) lives.""" + return f"{page}?tutorial={number}" + ("&demo=1" if demo else "") + + +def first_url(include_summary, demo=False): + """Where a run starts.""" + return step_url(steps_for(include_summary)[0]["page"], 1, demo) + + +def payload(path, step_arg, include_summary, demo=False): + """What the page needs to run the tour from this request, or None. + + None unless `step_arg` names a step that lives on `path`, so a stale or + hand-edited link renders the plain page instead of a tour pointing at + things that are not there. + """ + steps = steps_for(include_summary) + try: + number = int(step_arg) + except (TypeError, ValueError): + return None + if not 1 <= number <= len(steps) or steps[number - 1]["page"] != path: + return None + + return { + "current": number - 1, + "steps": [dict(s, url=step_url(s["page"], i, demo)) + for i, s in enumerate(steps, start=1)], + # Finishing stays on Summary, whose links the closing step has just + # pointed at. + "exit_url": SUMMARY + ("?demo=1" if demo else ""), + } diff --git a/static/common.css b/static/common.css index 4c919f8..f2cfd98 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); @@ -142,11 +143,11 @@ h1, h2, h3, h4, h5, h6 { z-index: 5; /* Bootstrap also defines .nav, and sets flex-wrap:wrap on it. Left to that, the banner folds onto a second row once the tab strip grows: at six nodes - on a 1400px screen the Retina Dashboard button dropped to its own line. + on a 1400px screen the last outbound button dropped to its own line. The tab strip scrolls instead (see .nav-tabs). */ flex-wrap: nowrap; } -/* The brand and the two outbound buttons keep their size; the tab strip is the +/* The brand and the outbound button keep their size; the tab strip is the only part that gives, so a long fleet never squashes the furniture around it. */ .nav > a { flex-shrink: 0; } .nav-tabs { @@ -168,7 +169,7 @@ h1, h2, h3, h4, h5, h6 { .nav-tabs > .nav-tab { flex-shrink: 0; } /* Below the app's narrow breakpoint there is not room for the brand, the tab - strip and both outbound buttons on one line, and holding nowrap there + strip and the outbound button on one line, and holding nowrap there squeezes the tab strip to nothing: every node tab disappears rather than scrolling. Wrapping is the right answer at this width (the footer already wraps), so the strip gets its own row and still scrolls within it. */ @@ -622,6 +623,31 @@ h1, h2, h3, h4, h5, h6 { /* Footer button groups */ .step-foot-btns { display: flex; align-items: center; gap: 8px; } +/* The complete step's invitation to the guided tutorial. + See docs/features/tutorial.md#starting-a-run */ +.wiz-tutorial { + margin-top: 14px; + display: flex; + gap: 14px; + align-items: flex-start; + padding: 16px 18px; + border: 1px solid var(--accent-edge); + background: var(--accent-soft); + border-radius: var(--r-md); +} +.wiz-tutorial-icon { + width: 32px; + height: 32px; + border-radius: var(--r-pill); + background: var(--accent); + color: #fff; + display: grid; + place-items: center; + flex-shrink: 0; +} +.wiz-tutorial-title { font-weight: 600; font-size: 14px; color: var(--ink); } +.wiz-tutorial-text { font-size: 13px; color: var(--ink-2); margin-top: 2px; } + @media (max-width: 600px) { .wiz { --wiz-pad: 16px; } .wiz-body { padding-top: 24px; } @@ -893,10 +919,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 +950,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 +1087,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); @@ -1285,7 +1334,7 @@ h1, h2, h3, h4, h5, h6 { .help-lead { margin: -6px 0 12px; font-size: 13px; color: var(--ink-2); max-width: 62ch; } /* ══════════════════════════════════════════════ - How your node sees: the flight path simulator + Doppler Mapping Tool: the flight path simulator ══════════════════════════════════════════════ */ .sim-head { margin-top: 28px; } @@ -1294,8 +1343,10 @@ h1, h2, h3, h4, h5, h6 { .sim-lead { margin: -6px 0 14px; font-size: 13px; line-height: 1.6; color: var(--ink-2); } -.sim { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; } -@media (max-width: 760px) { .sim { grid-template-columns: 1fr; } } +/* One above the other, each the full width of the column: the sky first, + then what the node records of it. Side by side they were too small to + draw in, and the sky could not show the whole beam. */ +.sim { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; } .sim-panel { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px 10px 8px; } @@ -1316,10 +1367,26 @@ h1, h2, h3, h4, h5, h6 { .sim-track { stroke: var(--accent); stroke-width: 1.8; fill: none; stroke-linecap: round; stroke-linejoin: round; } .sim-grid { stroke: var(--line-2); stroke-width: 1; } -.sim-axis-label { font-family: var(--font-body); font-size: 10px; fill: var(--ink-3); } +/* In the drawing's own units, so it scales with the panel: about 12px on a + desktop column, and held near 10px on a phone, where the panel is narrow. */ +.sim-axis-label { font-family: var(--font-body); font-size: 7.5px; fill: var(--ink-3); } +/* The sky is drawn 1.3 times further out than the plot, so its labels are + 1.3 times larger in its units to come out the same size on screen. */ +#fs-map .sim-axis-label { font-size: 9.75px; } +@media (max-width: 760px) { + .sim-axis-label { font-size: 12px; } + #fs-map .sim-axis-label { font-size: 15.6px; } +} .sim-mark { fill: var(--ink-2); } .sim-grab { cursor: grab; } .sim-grab:active { cursor: grabbing; } +/* No focus box, ever: grabbing the knob focuses it, and the browser's outline + then sat round it as a black rectangle. A keyboard user still needs to see + where they are, so for them the ring itself thickens. */ +.sim-grab:focus { outline: none; } +.sim-grab:focus-visible circle + circle { stroke-width: 3; } +.sim-aim-label { font-family: var(--font-body); font-size: 9.75px; font-weight: 600; fill: var(--accent); } +@media (max-width: 760px) { .sim-aim-label { font-size: 15.6px; } } .sim-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; } diff --git a/static/flight-sim.js b/static/flight-sim.js index 2504715..34f6df2 100644 --- a/static/flight-sim.js +++ b/static/flight-sim.js @@ -21,12 +21,23 @@ var aim = -Math.PI / 2, half = Math.PI / 3; // 120° across, pointing up var BASE = Math.hypot(T.x - N.x, T.y - N.y); - // The scale is chosen so the picture cannot reach its own axes. Drawn into - // the furthest corner this scene tops out at 51 km and 242 Hz, inside a - // plot that goes to 60 km and 300 Hz, so a track curves away from the edge - // instead of flattening along it. A track pinned to a bound is a lie: it - // says the aircraft stopped changing when really the plot ran out. - var KM = 0.15; // 1 unit ≈ 0.15 km, baseline ≈ 27 km + // How far the node hears, and how wide the beam can be opened. The sky's + // viewBox in summary.html is sized from these two, so the whole wedge is + // in the picture at its widest with sky to spare: change them and it has + // to follow. + // See docs/architecture.md#flight-simulator + var BEAM_R = 200, WIDEST = 1.35, NARROWEST = 0.2; + // The sky's viewBox, less a margin: where a path may be drawn. + var SKY = { left: -54, right: 478, top: -54, bottom: 247 }; + // The plot's box, as drawn in summary.html. + var PLOT = { x: 46, w: 356, mid: 98, h: 84 }; + + // The scale is chosen so the picture cannot reach its own axes. Heard at + // the far end of the beam this scene tops out at 50 km and 242 Hz, inside + // a plot that goes to 60 km and 300 Hz, so a track curves away from the + // edge instead of flattening along it. A track pinned to a bound is a lie: + // it says the aircraft stopped changing when really the plot ran out. + var KM = 0.125; // 1 unit = 0.125 km: baseline ≈ 22 km, beam ≈ 25 km var RANGE_MAX = 60; // km, the full width of the plot var DOPPLER_MAX = 300; // Hz, top and bottom of the plot @@ -40,7 +51,7 @@ var pts = []; for (var i = 0; i <= 40; i++) { var t = i / 40; - pts.push({ x: 20 + t * 265, y: 150 - Math.sin(t * Math.PI) * 96 }); + pts.push({ x: 20 + t * 380, y: 150 - Math.sin(t * Math.PI) * 118 }); } return pts; })(); @@ -57,6 +68,14 @@ } } + // The direction of travel through point k: from the point before it to + // the point after. + function tangent(k) { + var a = path[Math.max(0, k - 1)], b = path[Math.min(path.length - 1, k + 1)]; + var x = b.x - a.x, y = b.y - a.y, h = Math.hypot(x, y) || 1; + return { x: x / h, y: y / h }; + } + function at(d) { // Position and heading a distance d along the drawn path. if (total <= 0) return null; @@ -66,11 +85,18 @@ var a = path[i - 1], b = path[i] || path[i - 1]; var span = (lengths[i] || total) - lengths[i - 1]; var f = span > 0 ? (d - lengths[i - 1]) / span : 0; - var hx = b.x - a.x, hy = b.y - a.y, h = Math.hypot(hx, hy) || 1; - return { x: a.x + hx * f, y: a.y + hy * f, hx: hx / h, hy: hy / h }; + // The heading turns steadily from one point to the next. Taken from + // each straight piece instead, it would jump at every point, and + // Doppler with it: the track came out as a sawtooth. + var ta = tangent(i - 1), tb = tangent(Math.min(i, path.length - 1)); + var hx = ta.x + (tb.x - ta.x) * f, hy = ta.y + (tb.y - ta.y) * f, h = Math.hypot(hx, hy) || 1; + return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, hx: hx / h, hy: hy / h }; } + // Inside the wedge as it is drawn: within its angle and within its reach. + // The arc is in the picture, so an aircraft beyond it must not be heard. function inBeam(p) { + if (Math.hypot(p.x - N.x, p.y - N.y) > BEAM_R) return false; var delta = Math.atan2(p.y - N.y, p.x - N.x) - aim; while (delta > Math.PI) delta -= 2 * Math.PI; while (delta < -Math.PI) delta += 2 * Math.PI; @@ -98,7 +124,7 @@ } function drawBeam() { - var a1 = aim - half, a2 = aim + half, R = 200; + var a1 = aim - half, a2 = aim + half, R = BEAM_R; var p1 = { x: N.x + Math.cos(a1) * R, y: N.y + Math.sin(a1) * R }; var p2 = { x: N.x + Math.cos(a2) * R, y: N.y + Math.sin(a2) * R }; var d = "M" + N.x + " " + N.y + " L" + p1.x.toFixed(1) + " " + p1.y.toFixed(1) + @@ -126,9 +152,9 @@ // coordinate, not a working part: the scale above keeps every reachable // reading well inside the box. function place(r) { - var x = 46 + Math.max(0, Math.min(RANGE_MAX, r.km)) / RANGE_MAX * 240; + var x = PLOT.x + Math.max(0, Math.min(RANGE_MAX, r.km)) / RANGE_MAX * PLOT.w; var hz = Math.max(-DOPPLER_MAX, Math.min(DOPPLER_MAX, r.hz)); - return { x: x, y: 98 - hz / DOPPLER_MAX * 84 }; + return { x: x, y: PLOT.mid - hz / DOPPLER_MAX * PLOT.h }; } function paint(p) { @@ -207,7 +233,7 @@ // ── Drawing a path ──────────────────────────────────────── function inside(p) { - return { x: Math.max(4, Math.min(296, p.x)), y: Math.max(4, Math.min(216, p.y)) }; + return { x: Math.max(SKY.left, Math.min(SKY.right, p.x)), y: Math.max(SKY.top, Math.min(SKY.bottom, p.y)) }; } function svgPoint(svg, evt) { @@ -241,7 +267,7 @@ map.releasePointerCapture(evt.pointerId); if (path.length < 3) { path = []; drawPath(); hint.textContent = "That was a dot. Drag a line across the sky."; return; } measure(); - hint.textContent = "Drag the blue ring to steer the beam. The track breaks where the aircraft leaves it."; + hint.textContent = "Grab the blue ring to aim the beam. The track breaks where the aircraft leaves it."; play(); }); @@ -265,10 +291,10 @@ }); document.getElementById("fs-narrow").addEventListener("click", function () { - half = Math.max(0.2, half - 0.26); drawBeam(); resetTrack(); + half = Math.max(NARROWEST, half - 0.26); drawBeam(); resetTrack(); }); document.getElementById("fs-wide").addEventListener("click", function () { - half = Math.min(1.35, half + 0.26); drawBeam(); resetTrack(); + half = Math.min(WIDEST, half + 0.26); drawBeam(); resetTrack(); }); clearBtn.addEventListener("click", function () { pause(); resetTrack(); path = []; drawPath(); @@ -278,6 +304,9 @@ // ── First paint, standing still ─────────────────────────── var controls = document.getElementById("fs-controls"); if (controls) controls.hidden = false; + // The knob can be grabbed now, so it may say so. + var aimLabel = document.getElementById("fs-aim-label"); + if (aimLabel) aimLabel.removeAttribute("display"); measure(); drawBeam(); drawPath(); var start = at(0); diff --git a/static/tutorial.css b/static/tutorial.css new file mode 100644 index 0000000..fbb54cb --- /dev/null +++ b/static/tutorial.css @@ -0,0 +1,170 @@ +/* The guided tutorial overlay. Loaded only while a tour step lives on the page. + See docs/features/tutorial.md */ + +.tut { + position: fixed; + inset: 0; + /* Above the wizard's session notice (2000) and Bootstrap modals (1055). */ + z-index: 3000; + font-family: var(--font-body); + /* Swallows every click on the page underneath: the tour is look-only. */ + pointer-events: auto; +} + +.tut-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; +} +.tut-dim { fill: oklch(0.20 0.012 260 / 0.66); } + +/* The highlight: a bright ring on each lit region, with a slow pulse so the + eye finds it without the page having to move. */ +.tut-ring { + fill: none; + stroke: var(--accent); + stroke-width: 2.5; + filter: drop-shadow(0 0 6px oklch(0.75 0.15 250 / 0.9)); + animation: tut-pulse 1.8s ease-in-out infinite; +} +/* A click-through step: the ring is what to click, so it asks louder. */ +.tut-clickable .tut-ring { stroke-width: 3.5; animation-duration: 1.1s; } +@keyframes tut-pulse { + 0%, 100% { stroke-opacity: 1; } + 50% { stroke-opacity: 0.55; } +} + +.tut-arrow-halo { fill: none; stroke: #fff; stroke-width: 6; stroke-linecap: round; opacity: 0.95; } +.tut-arrow { fill: none; stroke: var(--accent); stroke-width: 3; stroke-linecap: round; } +.tut-arrow-head-halo { fill: #fff; stroke: #fff; stroke-width: 3; stroke-linejoin: round; opacity: 0.95; } +.tut-arrow-head { fill: var(--accent); } + +.tut-card { + position: absolute; + top: 0; + left: 0; + /* Narrow enough to stand in the Configuration page's side column without + covering the section it is describing. */ + width: 324px; + max-width: calc(100vw - 24px); + background: var(--surface); + color: var(--ink); + border: 1px solid var(--line); + border-radius: var(--r-lg); + box-shadow: 0 12px 40px oklch(0.20 0.012 260 / 0.35); + padding: 16px 18px 14px; + font-size: 14px; + line-height: 1.5; +} + +/* Hidden while the page scrolls to a step, then faded in where it belongs. + See holdUntilStill in tutorial.js. */ +.tut-card, .tut-arrows { transition: opacity .16s ease-out; } +.tut-moving .tut-card, .tut-moving .tut-arrows { opacity: 0; transition: none; } + +.tut-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-3); +} +.tut-bar { + flex: 1; + height: 3px; + border-radius: var(--r-pill); + background: var(--line); + overflow: hidden; +} +.tut-bar-fill { height: 100%; background: var(--accent); border-radius: var(--r-pill); transition: width .2s; } + +.tut-title { + font-size: 17px; + font-weight: 600; + letter-spacing: -0.01em; + margin: 10px 0 6px; +} +.tut-body { margin: 0; color: var(--ink-2); } + +/* A step with an example image: the picture has to be big enough to read, so + the card is too, and it stands over the page rather than in the margin. */ +.tut-card.tut-wide { width: 720px; } + +.tut-figure { margin: 10px 0 10px; } +.tut-frame { position: relative; } +.tut-marks { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; } +/* White on a dark edge, so a mark reads on the plot's purple and over a dot + of either colour. Widths are in screen pixels whatever the image's size. */ +.tut-mark, .tut-mark-shadow { fill: none; stroke-linecap: round; vector-effect: non-scaling-stroke; } +.tut-mark { stroke: #fff; stroke-width: 2.5; } +.tut-mark-shadow { stroke: oklch(0.20 0.012 260); stroke-width: 5.5; } +.tut-mark-tip { fill: #fff; } +.tut-mark-tip-shadow { fill: oklch(0.20 0.012 260); stroke: oklch(0.20 0.012 260); stroke-width: 3; stroke-linejoin: round; vector-effect: non-scaling-stroke; } +.tut-figure img { + display: block; + /* The img carries its own width and height, so this keeps its shape and + the card's height is known before the image arrives. */ + width: 100%; + height: auto; + border-radius: var(--r-md); + border: 1px solid var(--line); + background: var(--surface-2); +} +.tut-figure figcaption { font-size: 11.5px; color: var(--ink-3); margin-top: 4px; } + +.tut-link { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 8px; + font-size: 13px; + font-weight: 500; + color: var(--accent); + text-decoration: none; +} +.tut-link:hover { text-decoration: underline; } + +.tut-note { margin: 8px 0 0; font-size: 12.5px; color: var(--ink-3); } + +.tut-actions { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + padding-top: 12px; + border-top: 1px solid var(--line); +} +.tut-actions { flex-wrap: wrap; row-gap: 8px; } +.tut-nav { display: flex; gap: 8px; margin-left: auto; } +.tut-skip { + background: none; + border: 0; + padding: 6px 2px; + font: inherit; + font-size: 13px; + color: var(--ink-3); + cursor: pointer; + white-space: nowrap; + text-decoration: underline; + text-underline-offset: 3px; +} +.tut-skip:hover { color: var(--ink); } +/* `hidden` loses to any display rule above, so say it again. */ +.tut [hidden] { display: none !important; } +.tut-card:focus { outline: none; } +.tut-card :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +@media (max-width: 640px) { + .tut-card, .tut-card.tut-wide { width: calc(100vw - 24px); } +} + +@media (prefers-reduced-motion: reduce) { + .tut-ring { animation: none; } + .tut-bar-fill { transition: none; } + .tut-card, .tut-arrows { transition: none; } +} diff --git a/static/tutorial.js b/static/tutorial.js new file mode 100644 index 0000000..52b87a3 --- /dev/null +++ b/static/tutorial.js @@ -0,0 +1,682 @@ +// The guided tutorial: dims the page, lights up what the current step is +// about, and points at it from a card with Previous, Next and Skip tutorial. +// The steps, their order and their wording come from the server +// (window.OWL_TUTORIAL, built by src/tutorial.py); this file only draws them. +// See docs/features/tutorial.md +(function () { + 'use strict'; + + var data = window.OWL_TUTORIAL; + if (!data || !data.steps || !data.steps.length) return; + + var SVG_NS = 'http://www.w3.org/2000/svg'; + var PAD = 6; // breathing room around a lit region + var RADIUS = 10; // matches --r-md, so a lit card keeps its corners + var EDGE = 12; // the card's minimum distance from the viewport edge + var GAP = 44; // the card's distance from the region it describes + var MIN_ARROW = 24; // shorter than this and an arrow is only clutter + var HEAD = 11; // arrowhead length + var MARGIN_GAP = 30; // between a card in the margin and the page's column + var MIN_CARD = 220; // narrower than this, a card in the margin is unreadable + var MAX_CARD = 324; // the card's width when it has the room (as tutorial.css) + var MIN_WIDE = 460; // the least a picture card shrinks to before it overlaps instead + + var steps = data.steps; + var current = data.current; + var page = steps[current].page; + var closed = false; + var litNow = []; // the regions drawn by the last layout, for hit-testing + + // ── DOM ───────────────────────────────────────────────────── + + function el(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; + } + + function svgEl(tag, attrs) { + var node = document.createElementNS(SVG_NS, tag); + for (var key in attrs) node.setAttribute(key, attrs[key]); + return node; + } + + var root = el('div', 'tut'); + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + root.setAttribute('aria-labelledby', 'tutTitle'); + root.setAttribute('aria-describedby', 'tutBody'); + + var svg = svgEl('svg', { 'class': 'tut-svg', 'aria-hidden': 'true' }); + var defs = svgEl('defs', {}); + var mask = svgEl('mask', { id: 'tutMask', maskUnits: 'userSpaceOnUse' }); + defs.appendChild(mask); + svg.appendChild(defs); + var dim = svgEl('rect', { 'class': 'tut-dim', x: 0, y: 0, mask: 'url(#tutMask)' }); + svg.appendChild(dim); + var rings = svgEl('g', {}); + svg.appendChild(rings); + var arrows = svgEl('g', { 'class': 'tut-arrows' }); + svg.appendChild(arrows); + root.appendChild(svg); + + var card = el('div', 'tut-card'); + card.tabIndex = -1; + var meta = el('div', 'tut-meta'); + var count = el('span'); + var bar = el('div', 'tut-bar'); + var barFill = el('div', 'tut-bar-fill'); + bar.appendChild(barFill); + meta.appendChild(count); + meta.appendChild(bar); + + var title = el('h2', 'tut-title'); + title.id = 'tutTitle'; + var body = el('p', 'tut-body'); + body.id = 'tutBody'; + + var figure = el('figure', 'tut-figure'); + var frame = el('div', 'tut-frame'); + var image = el('img'); + // Drawn over the image in the image's own pixels, so a mark stays on its + // dot whatever size the image is shown at. + var marks = svgEl('svg', { 'class': 'tut-marks', 'aria-hidden': 'true', preserveAspectRatio: 'none' }); + var caption = el('figcaption', '', 'Example view'); + frame.appendChild(image); + frame.appendChild(marks); + figure.appendChild(frame); + figure.appendChild(caption); + + var link = el('a', 'tut-link'); + link.target = '_blank'; + link.rel = 'noopener'; + var note = el('p', 'tut-note'); + + var actions = el('div', 'tut-actions'); + var skipBtn = el('button', 'tut-skip', 'Skip tutorial'); + var prevBtn = el('button', 'ds-btn ghost', 'Previous'); + var nextBtn = el('button', 'ds-btn primary', 'Next'); + skipBtn.type = prevBtn.type = nextBtn.type = 'button'; + // Previous and Next travel together, so a narrow card wraps them onto + // their own line rather than splitting them. + var nav = el('div', 'tut-nav'); + nav.appendChild(prevBtn); + nav.appendChild(nextBtn); + actions.appendChild(skipBtn); + actions.appendChild(nav); + + [meta, title, body, figure, link, note, actions].forEach(function (node) { + card.appendChild(node); + }); + root.appendChild(card); + + // ── Geometry ──────────────────────────────────────────────── + + // The size of what the overlay covers. Not viewWidth(), which counts + // the scrollbar: page coordinates do not, so everything drawn would be + // scaled and drift off its target towards the far edge. + function viewWidth() { return root.clientWidth; } + function viewHeight() { return root.clientHeight; } + + function clamp(value, low, high) { + return Math.max(low, Math.min(high, value)); + } + + // True for anything that stays put while the page scrolls (the banner, the + // config side nav, the save bar): scrolling to it would achieve nothing. + function isPinned(node) { + for (; node && node !== document.body; node = node.parentElement) { + var position = getComputedStyle(node).position; + if (position === 'fixed' || position === 'sticky') return true; + } + return false; + } + + // How far a lit region may extend past its elements on each side: PAD, + // or half the gap to the nearest neighbouring element when that is less, + // so the highlight never spills onto something it is not about. + function paddingFor(box, nodes) { + var gap = { left: Infinity, top: Infinity, right: Infinity, bottom: Infinity }; + nodes.forEach(function (node) { + var siblings = node.parentElement ? node.parentElement.children : []; + for (var i = 0; i < siblings.length; i++) { + if (nodes.indexOf(siblings[i]) !== -1) continue; + var s = siblings[i].getBoundingClientRect(); + if (s.width < 1 || s.height < 1) continue; + var level = s.top < box.bottom && s.bottom > box.top; + var inline = s.left < box.right && s.right > box.left; + if (level && s.right <= box.left) gap.left = Math.min(gap.left, box.left - s.right); + if (level && s.left >= box.right) gap.right = Math.min(gap.right, s.left - box.right); + if (inline && s.bottom <= box.top) gap.top = Math.min(gap.top, box.top - s.bottom); + if (inline && s.top >= box.bottom) gap.bottom = Math.min(gap.bottom, s.top - box.bottom); + } + }); + function pad(space) { return Math.min(PAD, Math.floor(space / 2)); } + return { left: pad(gap.left), top: pad(gap.top), right: pad(gap.right), bottom: pad(gap.bottom) }; + } + + // One lit region per target: the box around every visible element that + // carries the target's name. Null when none is on the page. + function regionFor(target) { + var all = document.querySelectorAll('[data-tutorial~="' + target.name + '"]'); + var nodes = []; + var box = null; + var pinned = true; + for (var i = 0; i < all.length; i++) { + var r = all[i].getBoundingClientRect(); + if (r.width < 1 || r.height < 1) continue; + nodes.push(all[i]); + if (!box) { + box = { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; + } else { + box.left = Math.min(box.left, r.left); + box.top = Math.min(box.top, r.top); + box.right = Math.max(box.right, r.right); + box.bottom = Math.max(box.bottom, r.bottom); + } + if (!isPinned(all[i])) pinned = false; + } + if (!box) return null; + var pad = paddingFor(box, nodes); + return { + left: box.left - pad.left, top: box.top - pad.top, + right: box.right + pad.right, bottom: box.bottom + pad.bottom, + arrow: target.arrow, pinned: pinned + }; + } + + function regions() { + var found = []; + steps[current].targets.forEach(function (target) { + var region = regionFor(target); + if (region) found.push(region); + }); + return found; + } + + function onScreen(r) { + return r.right > 0 && r.bottom > 0 && r.left < viewWidth() && r.top < viewHeight(); + } + + function overlap(a, b) { + var w = Math.min(a.right, b.right) - Math.max(a.left, b.left); + var h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + return w > 0 && h > 0 ? w * h : 0; + } + + function stickyHeadHeight() { + var nav = document.querySelector('.page > .nav'); + if (!nav || getComputedStyle(nav).position !== 'sticky') return 0; + return nav.getBoundingClientRect().height; + } + + // The empty margins either side of the page's column, and how wide a card + // standing in one can be. Null when the window is too narrow for a card + // to fit beside the page. See docs/features/tutorial.md#the-overlay + function margins() { + // A step with a picture uses a wide card, which no margin can hold. + if (steps[current].image) return null; + var column = document.querySelector('.shell') || document.querySelector('main.main'); + if (!column) return null; + var box = column.getBoundingClientRect(); + var style = getComputedStyle(column); + // The column's own padding is empty too. Lit rings reach PAD beyond it. + var left = box.left + parseFloat(style.paddingLeft) - PAD; + var right = box.right - parseFloat(style.paddingRight) + PAD; + var room = Math.min(left, viewWidth() - right) - EDGE - MARGIN_GAP; + if (room < MIN_CARD) return null; + return { left: left, right: right, width: Math.min(MAX_CARD, room) }; + } + + // Bring the step's regions into view. Returns true when the page is left + // gliding there, false when it is already in place. + // With the card in the margin the + // region is simply centred. Otherwise room is left for the card: region + // then card, centred, when both fit; the region at the bottom with the + // card above it when only the region fits. Either way a region taller + // than the window is top-aligned under the banner. + function scrollIntoPlace(smooth) { + var moving = regions().filter(function (r) { return !r.pinned; }); + if (!moving.length) return false; + var top = Infinity; + var bottom = -Infinity; + moving.forEach(function (r) { + top = Math.min(top, r.top); + bottom = Math.max(bottom, r.bottom); + }); + var roomTop = stickyHeadHeight() + 16; + var roomBottom = viewHeight() - 16; + var room = roomBottom - roomTop; + var height = bottom - top; + var withCard = margins() ? height : height + GAP + card.offsetHeight; + var wanted; + if (withCard <= room) { + wanted = roomTop + (room - withCard) / 2; + } else if (height <= room) { + wanted = roomBottom - height; + } else { + wanted = roomTop; + } + if (Math.abs(top - wanted) < 2) return false; + var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + var glide = smooth && !reduce; + window.scrollTo({ top: window.scrollY + top - wanted, behavior: glide ? 'smooth' : 'auto' }); + return glide; + } + + function gapBetween(a, b) { + var dx = Math.max(0, a.left - b.right, b.left - a.right); + var dy = Math.max(0, a.top - b.bottom, b.top - a.bottom); + return Math.sqrt(dx * dx + dy * dy); + } + + // Put the card where it covers the least of what is lit, as close to the + // region it describes as that allows. The preferred spots sit beside that + // region; the grid behind them is what finds a place when a region is as + // wide or as tall as the window and some overlap cannot be avoided. + function placeCard(lit) { + var vw = viewWidth(); + var vh = viewHeight(); + var side = margins(); + var anchor = lit.filter(function (r) { return r.arrow; })[0] || lit[0]; + // Set before measuring: the card's height follows from its width. + card.style.width = side ? side.width + 'px' : ''; + // A wide card that fits neither above nor below its region has to + // stand beside it, so it gives up width (and picture size) rather + // than cover the very thing it is pointing at. + if (steps[current].image && anchor) { + var tall = card.offsetHeight + GAP + EDGE; + var above = anchor.top - stickyHeadHeight() >= tall; + var below = vh - anchor.bottom >= tall; + if (!above && !below) { + var beside = Math.max(anchor.left, vw - anchor.right) - GAP - EDGE; + if (beside < card.offsetWidth) card.style.width = Math.max(MIN_WIDE, beside) + 'px'; + } + } + var cw = card.offsetWidth; + var ch = card.offsetHeight; + var maxX = Math.max(EDGE, vw - cw - EDGE); + var maxY = Math.max(EDGE, vh - ch - EDGE); + var middleY = anchor ? (clamp(anchor.top, 0, vh) + clamp(anchor.bottom, 0, vh)) / 2 : vh / 2; + var best = null; + + function consider(x, y, penalty) { + x = clamp(x, EDGE, maxX); + y = clamp(y, EDGE, maxY); + var box = { left: x - 10, top: y - 10, right: x + cw + 10, bottom: y + ch + 10 }; + var cost = penalty; + lit.forEach(function (r) { cost += overlap(box, r); }); + if (anchor) { + // Too close leaves no room for the arrow; too far loses the link. + var gap = gapBetween({ left: x, top: y, right: x + cw, bottom: y + ch }, anchor); + cost += gap < GAP ? (GAP - gap) * 4 : (gap - GAP) * 0.5; + } + if (!best || cost < best.cost) best = { x: x, y: y, cost: cost }; + } + + // In the margin: beside the page, level with the region, on whichever + // side is nearer to it, so the page itself stays fully in view. + function considerMargin(x, y, level) { + var box = { left: x - 10, top: y - 10, right: x + cw + 10, bottom: y + ch + 10 }; + var cost = Math.abs(y - level) * 0.5 + + Math.max(0, anchor.left - (x + cw), x - anchor.right); + // A region that is itself in the margin (the Config link) needs + // the card to stand clear of it, or there is no room for an arrow. + var gap = gapBetween({ left: x, top: y, right: x + cw, bottom: y + ch }, anchor); + if (gap < MARGIN_GAP) cost += (MARGIN_GAP - gap) * 4; + lit.forEach(function (r) { cost += overlap(box, r); }); + if (!best || cost < best.cost) best = { x: x, y: y, cost: cost }; + } + + if (!anchor) { + consider((vw - cw) / 2, (vh - ch) / 2, 0); + } else if (side) { + var minY = Math.min(stickyHeadHeight() + EDGE, maxY); + // Centred on the part of the region that is on screen, so the + // arrow runs straight across and the card is not pushed to the + // bottom of the window by a region that sits low on the page. + var level = clamp(middleY - ch / 2, minY, maxY); + [side.left - MARGIN_GAP - cw, side.right + MARGIN_GAP].forEach(function (x) { + considerMargin(x, level, level); + for (var y = minY; y <= maxY; y += 12) considerMargin(x, y, level); + considerMargin(x, maxY, level); + }); + } else { + var mid = (anchor.left + anchor.right) / 2 - cw / 2; + [ + [mid, anchor.bottom + GAP], + [mid, anchor.top - GAP - ch], + [anchor.right + GAP, middleY - ch / 2], + [anchor.left - GAP - cw, middleY - ch / 2], + [anchor.left, anchor.bottom + GAP], + [anchor.left, anchor.top - GAP - ch] + ].forEach(function (spot, order) { consider(spot[0], spot[1], order); }); + for (var gx = EDGE; gx <= maxX; gx += 24) { + for (var gy = EDGE; gy <= maxY; gy += 24) consider(gx, gy, 40); + } + // The far edges, which the grid's stride can step over. + consider(maxX, maxY, 40); + consider(EDGE, maxY, 40); + consider(maxX, EDGE, 40); + } + + card.style.transform = 'translate(' + Math.round(best.x) + 'px,' + Math.round(best.y) + 'px)'; + return { left: best.x, top: best.y, right: best.x + cw, bottom: best.y + ch }; + } + + function drawArrow(from, region) { + var vw = viewWidth(); + var vh = viewHeight(); + // Aim at the part of the region that is actually on screen. + var box = { + left: clamp(region.left, 0, vw), right: clamp(region.right, 0, vw), + top: clamp(region.top, 0, vh), bottom: clamp(region.bottom, 0, vh) + }; + // End at the middle of whichever edge of the region faces the card, + // never at a corner: beside it when the two are side by side (or + // further apart sideways than vertically), otherwise above or below. + var gapX = Math.max(box.left - from.right, from.left - box.right); + var gapY = Math.max(box.top - from.bottom, from.top - box.bottom); + if (gapX <= 0 && gapY <= 0) return; // the card is over the region + var INSET = 18; // keeps the tail off the card's rounded corners + var startX, startY, endX, endY; + if (gapY <= 0 || gapX >= gapY) { + var toRight = from.right <= box.left; + endX = toRight ? box.left : box.right; + endY = (box.top + box.bottom) / 2; + startX = toRight ? from.right : from.left; + startY = clamp(endY, from.top + INSET, from.bottom - INSET); + } else { + var down = from.bottom <= box.top; + endY = down ? box.top : box.bottom; + endX = (box.left + box.right) / 2; + startY = down ? from.bottom : from.top; + startX = clamp(endX, from.left + INSET, from.right - INSET); + } + var dx = endX - startX; + var dy = endY - startY; + var length = Math.sqrt(dx * dx + dy * dy); + if (length < MIN_ARROW) return; + + // A slight bow, so the arrow reads as drawn rather than ruled. + var ux = dx / length; + var uy = dy / length; + var bow = Math.min(18, length * 0.12); + var ctrlX = (startX + endX) / 2 - uy * bow; + var ctrlY = (startY + endY) / 2 + ux * bow; + + // The head follows the curve's final direction. + var hx = endX - ctrlX; + var hy = endY - ctrlY; + var hl = Math.sqrt(hx * hx + hy * hy) || 1; + hx /= hl; + hy /= hl; + var baseX = endX - hx * HEAD; + var baseY = endY - hy * HEAD; + var line = 'M' + startX + ' ' + startY + ' Q' + ctrlX + ' ' + ctrlY + ' ' + baseX + ' ' + baseY; + var half = HEAD * 0.55; + var head = 'M' + endX + ' ' + endY + + ' L' + (baseX - hy * half) + ' ' + (baseY + hx * half) + + ' L' + (baseX + hy * half) + ' ' + (baseY - hx * half) + ' Z'; + + // Both white outlines go down first and both blue shapes on top, so + // the head's outline never cuts across the line where they join. + arrows.appendChild(svgEl('path', { 'class': 'tut-arrow-halo', d: line })); + arrows.appendChild(svgEl('path', { 'class': 'tut-arrow-head-halo', d: head })); + arrows.appendChild(svgEl('path', { 'class': 'tut-arrow', d: line })); + arrows.appendChild(svgEl('path', { 'class': 'tut-arrow-head', d: head })); + } + + // Rings and arrows over the example image, one per point the step names. + function drawMarks(picture) { + clear(marks); + marks.setAttribute('viewBox', '0 0 ' + picture.width + ' ' + picture.height); + var unit = picture.width / 100; // everything scales with the image + var radius = 2.1 * unit; + picture.marks.forEach(function (mark) { + // The arrow comes from above right, or from wherever there is room. + var fromX = mark.x + (mark.x > picture.width * 0.86 ? -1 : 1) * 6.5 * unit; + var fromY = mark.y + (mark.y < picture.height * 0.22 ? 1 : -1) * 6.5 * unit; + var dx = mark.x - fromX; + var dy = mark.y - fromY; + var length = Math.sqrt(dx * dx + dy * dy); + var ux = dx / length; + var uy = dy / length; + var tipX = mark.x - ux * (radius + 0.5 * unit); + var tipY = mark.y - uy * (radius + 0.5 * unit); + var head = 2.2 * unit; + var baseX = tipX - ux * head; + var baseY = tipY - uy * head; + var half = head * 0.55; + var line = 'M' + fromX + ' ' + fromY + ' L' + baseX + ' ' + baseY; + var tip = 'M' + tipX + ' ' + tipY + + ' L' + (baseX - uy * half) + ' ' + (baseY + ux * half) + + ' L' + (baseX + uy * half) + ' ' + (baseY - ux * half) + ' Z'; + var ring = { cx: mark.x, cy: mark.y, r: radius }; + marks.appendChild(svgEl('circle', Object.assign({ 'class': 'tut-mark-shadow' }, ring))); + marks.appendChild(svgEl('path', { 'class': 'tut-mark-shadow', d: line })); + marks.appendChild(svgEl('path', { 'class': 'tut-mark-tip-shadow', d: tip })); + marks.appendChild(svgEl('circle', Object.assign({ 'class': 'tut-mark' }, ring))); + marks.appendChild(svgEl('path', { 'class': 'tut-mark', d: line })); + marks.appendChild(svgEl('path', { 'class': 'tut-mark-tip', d: tip })); + }); + } + + function clear(node) { + while (node.firstChild) node.removeChild(node.firstChild); + } + + function layout() { + if (closed) return; + var vw = viewWidth(); + var vh = viewHeight(); + var all = regions(); + var lit = all.filter(onScreen); + litNow = lit; + + svg.setAttribute('viewBox', '0 0 ' + vw + ' ' + vh); + dim.setAttribute('width', vw); + dim.setAttribute('height', vh); + mask.setAttribute('x', 0); + mask.setAttribute('y', 0); + mask.setAttribute('width', vw); + mask.setAttribute('height', vh); + + clear(mask); + clear(rings); + clear(arrows); + mask.appendChild(svgEl('rect', { x: 0, y: 0, width: vw, height: vh, fill: '#fff' })); + lit.forEach(function (r) { + var shape = { x: r.left, y: r.top, width: r.right - r.left, height: r.bottom - r.top, rx: RADIUS }; + var hole = svgEl('rect', shape); + hole.setAttribute('fill', '#000'); + mask.appendChild(hole); + var ring = svgEl('rect', shape); + ring.setAttribute('class', 'tut-ring'); + rings.appendChild(ring); + }); + + // Said only when there is truly nothing to point at (for example the + // Services cards while the node is in Spectrum mode). + var nothing = all.length === 0; + note.hidden = !nothing; + if (nothing) note.textContent = 'This part of the page is not shown right now, so there is nothing to point at.'; + + var box = placeCard(lit); + var pointed = lit.filter(function (r) { return r.arrow; }); + if (!pointed.length) pointed = lit; + pointed.forEach(function (r) { drawArrow(box, r); }); + } + + var queued = false; + function requestLayout() { + if (queued || closed) return; + queued = true; + window.requestAnimationFrame(function () { + queued = false; + layout(); + }); + } + + // While a step's scroll is in flight the card and arrows are hidden. Laid + // out mid-glide they are drawn for where the region happens to be, and + // then jump when it stops; a picture card, which stands over the page, + // can land somewhere else entirely for a moment. The rings stay: they + // only follow. See docs/features/tutorial.md#the-overlay + var STILL = 140; // ms without a scroll event before the page counts as stopped + var stillTimer = null; + function holdUntilStill() { + root.classList.add('tut-moving'); + window.clearTimeout(stillTimer); + stillTimer = window.setTimeout(function () { + stillTimer = null; + if (closed) return; + root.classList.remove('tut-moving'); + layout(); + }, STILL); + } + function onScroll() { + if (stillTimer !== null) holdUntilStill(); + requestLayout(); + } + + // ── Steps ─────────────────────────────────────────────────── + + function render(smooth) { + var step = steps[current]; + count.textContent = 'Tutorial · Step ' + (current + 1) + ' of ' + steps.length; + barFill.style.width = ((current + 1) / steps.length * 100) + '%'; + title.textContent = step.title; + body.textContent = step.body; + + figure.hidden = !step.image; + // Wide enough to read the picture; see .tut-wide in tutorial.css. + card.classList.toggle('tut-wide', !!step.image); + if (step.image) { + drawMarks(step.image); + // The size comes with the step, so the card's height is right + // before the image has loaded. + image.width = step.image.width; + image.height = step.image.height; + image.src = step.image.url; + image.alt = step.image.alt; + } + link.hidden = !step.link; + if (step.link) { + link.href = step.link.url; + link.textContent = step.link.label + ' ↗'; + } + + prevBtn.disabled = current === 0; + nextBtn.textContent = current === steps.length - 1 ? 'Finish' : 'Next'; + // A click-through step has no Next: the owner goes on by clicking the + // lit thing itself. See docs/features/tutorial.md#click-through-steps + nextBtn.hidden = step.click; + root.classList.toggle('tut-clickable', step.click); + + if (scrollIntoPlace(smooth)) holdUntilStill(); + layout(); + // With no Next to land on, focus goes to the card, so that no other + // button looks like the suggested one. + (step.click ? card : nextBtn).focus({ preventScroll: true }); + } + + function go(index) { + if (index < 0) return; + if (index >= steps.length) { + window.location.assign(data.exit_url); + return; + } + var step = steps[index]; + // A step on another page is a real navigation; the server hands that + // page the same run with this step current. + if (step.page !== page) { + window.location.assign(step.url); + return; + } + current = index; + // So a reload resumes on the step being shown, not the one arrived at. + window.history.replaceState(null, '', step.url); + render(true); + } + + function close() { + closed = true; + document.removeEventListener('keydown', onKey, true); + window.removeEventListener('resize', requestLayout); + window.removeEventListener('scroll', onScroll, true); + window.clearTimeout(stillTimer); + if (observer) observer.disconnect(); + root.parentNode.removeChild(root); + // Drop ?tutorial= so a reload does not bring the tour back. + var params = new URLSearchParams(window.location.search); + params.delete('tutorial'); + var query = params.toString(); + window.history.replaceState(null, '', window.location.pathname + (query ? '?' + query : '')); + } + + function onKey(event) { + if (event.key === 'Escape') { + event.preventDefault(); + close(); + } else if (event.key === 'ArrowRight') { + event.preventDefault(); + // Not on a click-through step: there the click is the only way on. + if (!steps[current].click) go(current + 1); + } else if (event.key === 'ArrowLeft') { + event.preventDefault(); + go(current - 1); + } else if (event.key === 'Tab') { + // Keep the keyboard in the card: everything behind it is dimmed + // and not meant to be operated mid-tour. + var stops = [skipBtn, prevBtn, nextBtn]; + if (!link.hidden) stops.unshift(link); + stops = stops.filter(function (node) { return !node.disabled && !node.hidden; }); + var at = stops.indexOf(document.activeElement); + var next = event.shiftKey ? at - 1 : at + 1; + if (at === -1) next = 0; + event.preventDefault(); + stops[(next + stops.length) % stops.length].focus({ preventScroll: true }); + } + } + + // On a click-through step the lit regions take the click. The overlay still + // receives it, not the page: what the lit link really points at is another + // host's address, and the tour has to stay on the node that is serving it. + function overRegion(event) { + if (!steps[current].click || card.contains(event.target)) return false; + return litNow.some(function (r) { + return event.clientX >= r.left && event.clientX <= r.right && + event.clientY >= r.top && event.clientY <= r.bottom; + }); + } + root.addEventListener('click', function (event) { + if (overRegion(event)) go(current + 1); + }); + root.addEventListener('mousemove', function (event) { + root.style.cursor = overRegion(event) ? 'pointer' : ''; + }); + + skipBtn.addEventListener('click', close); + prevBtn.addEventListener('click', function () { go(current - 1); }); + nextBtn.addEventListener('click', function () { go(current + 1); }); + image.addEventListener('load', requestLayout); + + document.addEventListener('keydown', onKey, true); + window.addEventListener('resize', requestLayout); + // Capture, so scrolling inside any nested scroller also moves the regions. + window.addEventListener('scroll', onScroll, true); + // Pages keep changing height after load (status rows filling in), which + // moves the regions without any scroll or resize event. + var observer = window.ResizeObserver ? new ResizeObserver(requestLayout) : null; + if (observer) observer.observe(document.body); + window.addEventListener('load', function () { + if (closed) return; + scrollIntoPlace(false); + requestLayout(); + }); + + document.body.appendChild(root); + render(false); +})(); diff --git a/static/tutorial/adsb-map.jpg b/static/tutorial/adsb-map.jpg new file mode 100644 index 0000000..eb03725 Binary files /dev/null and b/static/tutorial/adsb-map.jpg differ diff --git a/static/tutorial/max-hold.jpg b/static/tutorial/max-hold.jpg new file mode 100644 index 0000000..03f77d7 Binary files /dev/null and b/static/tutorial/max-hold.jpg differ diff --git a/static/tutorial/passive-radar.jpg b/static/tutorial/passive-radar.jpg new file mode 100644 index 0000000..677bc3b Binary files /dev/null and b/static/tutorial/passive-radar.jpg differ diff --git a/static/tutorial/signal-overload.png b/static/tutorial/signal-overload.png new file mode 100644 index 0000000..8b0f4fd Binary files /dev/null and b/static/tutorial/signal-overload.png differ diff --git a/static/tutorial/signal-peak.png b/static/tutorial/signal-peak.png new file mode 100644 index 0000000..8ee2d34 Binary files /dev/null and b/static/tutorial/signal-peak.png differ diff --git a/static/tutorial/tracker.jpg b/static/tutorial/tracker.jpg new file mode 100644 index 0000000..29b27a7 Binary files /dev/null and b/static/tutorial/tracker.jpg differ diff --git a/templates/_fleet_bar.html b/templates/_fleet_bar.html index 077e459..3d6e03f 100644 --- a/templates/_fleet_bar.html +++ b/templates/_fleet_bar.html @@ -26,14 +26,17 @@ {% endif %} - {# Both of these leave the app, so both carry the outbound arrow. #} - + {# The network's dashboard and map, one site. It leaves the app, so it + carries the outbound arrow. Same address as RESOURCES in routes/fleet.py. + See docs/features/fleet-and-naming.md#resources-and-help-links #} + - Retina Network Map - - - - Retina Dashboard + Network Map and Dashboard {% endmacro %} diff --git a/templates/base.html b/templates/base.html index 0eed788..4a1e8c9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,6 +11,7 @@ + {% if tutorial %}{% endif %} {% block head %}{% endblock %} @@ -26,7 +27,7 @@ {% block subnav %} {% endblock %} @@ -52,5 +53,11 @@ {% block scripts %}{% endblock %} +{# The guided tutorial, drawn over this page. Only present while a tour step + lives here. See docs/features/tutorial.md #} +{% if tutorial %} + + +{% endif %} diff --git a/templates/config.html b/templates/config.html index 95ec138..16a5230 100644 --- a/templates/config.html +++ b/templates/config.html @@ -7,7 +7,9 @@ {% endblock %} {# ── Macro: render a single form field as a cfg-row ─────────────────────── #} -{% macro render_field(field, section, prefix='') %} +{# tutorial names the row as a guided-tutorial target (plain inputs and switches). + See docs/features/tutorial.md#targets #} +{% macro render_field(field, section, prefix='', tutorial='') %} {% set field_name = section ~ '.' ~ (prefix ~ field.name if prefix else field.name) %} {% set field_id = field_name | replace('.', '_') %} {% set field_err = config_errors.get(field_name) if config_errors else none %} @@ -36,7 +38,7 @@ {% elif field.type == 'checkbox' %} -
+
{% else %} -
+
{% if field.description %}{{ field.description }}{% endif %} @@ -82,32 +84,38 @@ {% block content %}
- {# ── Side nav ── #} -