texas2step 2.1.0__tar.gz → 2.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {texas2step-2.1.0 → texas2step-2.2.0}/PKG-INFO +1 -1
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/configuration.md +31 -5
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/development.md +31 -5
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/experience.md +3 -2
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/methodology.md +32 -14
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/roadmap.md +16 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/__init__.py +1 -1
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/config.py +18 -5
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/config_template.toml +8 -1
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/page.py +37 -2
- texas2step-2.2.0/src/texas2step/static/assets/app.css +1 -0
- texas2step-2.2.0/src/texas2step/static/assets/app.js +8 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/index.html +3 -2
- texas2step-2.1.0/src/texas2step/static/assets/app.css +0 -1
- texas2step-2.1.0/src/texas2step/static/assets/app.js +0 -8
- {texas2step-2.1.0 → texas2step-2.2.0}/.gitignore +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/LICENSE.md +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/README.md +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/deploy/nginx/texas2step-http.conf +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/deploy/nginx/texas2step-log.conf +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/deploy/nginx/texas2step-speedtest.conf +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/deploy/nginx/texas2step.conf +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/deploy/texas2step-update.service +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/deploy/texas2step-update.timer +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/deploy/texas2step.default +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/deploy/texas2step.service +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/access.md +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/api.md +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/images/hero-dark.jpg +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/images/results-dark.jpg +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/images/results-light.jpg +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/images/upload-light.jpg +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/install.md +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/security.md +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/docs/traffic.md +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/legacy/README.md +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/pyproject.toml +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/__main__.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/access.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/__init__.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/access.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/measure.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/meta.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/results.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/api/wire.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/app.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/cli.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/cluster.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/config_edit.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/config_merge.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/engine.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/iface.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/lockouts.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/logs.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/payload.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/quality.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/rtc.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/LICENSE +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/NOTICE +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/swagger-ui-bundle.js.LICENSE.txt +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/assets/swagger/swagger-ui.css +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/static/favicon.svg +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/stats.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/store.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/traffic.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/update.py +0 -0
- {texas2step-2.1.0 → texas2step-2.2.0}/src/texas2step/words.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: texas2step
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Web-centric throughput, latency, jitter and packet-loss measurement between endpoints.
|
|
5
5
|
Project-URL: Homepage, https://github.com/robert-patrick-texas/texas2step
|
|
6
6
|
Project-URL: Documentation, https://robert-patrick-texas.github.io/texas2step/
|
|
@@ -70,6 +70,8 @@ file up to date with the new version's template and keeps your values; see
|
|
|
70
70
|
| `brand_logo` | `""` | Path to an SVG, PNG, JPEG or WebP file shown in place of the star |
|
|
71
71
|
| `brand_notice` | `""` | A line of text across the top of every page |
|
|
72
72
|
| `brand_links` | `[]` | Links in the header and footer, as `[[brand_links]]` tables (TOML only) |
|
|
73
|
+
| `brand_footer` | `""` | Your own footer's text, near the bottom of every page: each line a line, a blank line a new paragraph, and `[label](url)` a link. See [Branding and themes](#branding-and-themes). |
|
|
74
|
+
| `brand_footer_links` | `[]` | Your own footer's links, beside its text, as `[[brand_footer_links]]` tables (TOML only) |
|
|
73
75
|
| `brand_credit` | `link` | The footer's "texas2step <version>": `link` to the project's site, plain `text`, or `none` to leave the project's name and version out of the footer |
|
|
74
76
|
| `default_theme` | `system` | `system`, `light`, `dark`, `america`, `energy`, `federal`, `military` or `texas` |
|
|
75
77
|
|
|
@@ -91,8 +93,8 @@ sudo systemctl restart texas2step
|
|
|
91
93
|
setting commented out, the line is filled in where it stands; otherwise it is added
|
|
92
94
|
above the first `[[table]]`. A value is read as TOML when it is valid TOML (`61000`,
|
|
93
95
|
`true`, `["a", "b"]`), and as text otherwise. Unknown settings, and values the server
|
|
94
|
-
would refuse, are rejected before anything is written. Edit `[[locations]]
|
|
95
|
-
`[[brand_links]]` in the file, or use `add-location`.
|
|
96
|
+
would refuse, are rejected before anything is written. Edit `[[locations]]`,
|
|
97
|
+
`[[brand_links]]` and `[[brand_footer_links]]` in the file, or use `add-location`.
|
|
96
98
|
* **`config add-location URL`** lists another texas2step server in the location picker.
|
|
97
99
|
It asks that server for its id, name and location (`/api/v1/info`), and for its
|
|
98
100
|
IPv4-only and IPv6-only URLs (its own entry in `/api/v1/locations`), using the
|
|
@@ -119,6 +121,10 @@ brand_url = "https://www.acme.example" # "Acme IT" links here
|
|
|
119
121
|
brand_logo = "/etc/texas2step/logo.svg" # replaces the star mark
|
|
120
122
|
brand_notice = "Authorized use only. Activity on this system may be monitored."
|
|
121
123
|
default_theme = "federal"
|
|
124
|
+
# Your own footer, near the bottom of every page (before any [[table]], as TOML needs).
|
|
125
|
+
brand_footer = """
|
|
126
|
+
© 2026 Acme IT. For official use only.
|
|
127
|
+
Questions? Ask the [service desk](https://help.acme.example) or call [555-0100](tel:5550100)."""
|
|
122
128
|
|
|
123
129
|
[[brand_links]]
|
|
124
130
|
label = "Service desk"
|
|
@@ -127,6 +133,14 @@ url = "https://help.acme.example"
|
|
|
127
133
|
[[brand_links]]
|
|
128
134
|
label = "Email the NOC"
|
|
129
135
|
url = "mailto:noc@acme.example"
|
|
136
|
+
|
|
137
|
+
[[brand_footer_links]]
|
|
138
|
+
label = "Privacy"
|
|
139
|
+
url = "https://www.acme.example/privacy"
|
|
140
|
+
|
|
141
|
+
[[brand_footer_links]]
|
|
142
|
+
label = "Accessibility"
|
|
143
|
+
url = "https://www.acme.example/accessibility"
|
|
130
144
|
```
|
|
131
145
|
|
|
132
146
|
* **Where it shows.** The name and logo lead the header. The organization sits beside them
|
|
@@ -137,13 +151,25 @@ url = "mailto:noc@acme.example"
|
|
|
137
151
|
words without the link, and `brand_credit = "none"` leaves the project's name, version
|
|
138
152
|
and "Powered by" out of the footer, for sites that carry only their own branding. The
|
|
139
153
|
version is still at `/api/v1/health`.
|
|
154
|
+
* **Your own footer.** `brand_footer` and `[[brand_footer_links]]` add a footer of your
|
|
155
|
+
own near the bottom of every page, the access-code screen included, above the line with
|
|
156
|
+
the organization and the credit. texas2step lays it out the same way on every site and
|
|
157
|
+
in every theme. On wide screens your text is on the left and your links are stacked
|
|
158
|
+
on the right. On phones the links follow the text, and with only one of the two it is
|
|
159
|
+
centered. Each line of `brand_footer` starts a new line, and a blank line starts a
|
|
160
|
+
paragraph. The text is plain, but `[label](url)` makes a link of the label, by the same
|
|
161
|
+
rules as the other links below. A URL ends at the first `)`. HTML isn't allowed, so it
|
|
162
|
+
shows as typed. In TOML, `"""` lets the text run over several lines. The API explorer
|
|
163
|
+
(`/api/docs`) is a separate page and has no footer of yours.
|
|
140
164
|
* **Logo.** SVG, PNG, JPEG, WebP or GIF, about 30 px tall on screen; wide logos keep their
|
|
141
165
|
shape, up to 180 px wide. The `texas2step` service user must be able to read the file
|
|
142
166
|
(`/etc/texas2step/` is a good place). If the file is missing or unreadable, the server
|
|
143
167
|
logs a warning and keeps the star.
|
|
144
|
-
* **Links** must start with `https://`, `http://`, `mailto:` or `tel
|
|
145
|
-
|
|
146
|
-
|
|
168
|
+
* **Links** must start with `https://`, `http://`, `mailto:` or `tel:`, in
|
|
169
|
+
`[[brand_links]]`, `[[brand_footer_links]]` and `brand_footer` alike. Anything else
|
|
170
|
+
stops the server at startup with an error naming the setting. `[[brand_links]]` and
|
|
171
|
+
`[[brand_footer_links]]` can only be set in the TOML file, not through environment
|
|
172
|
+
variables.
|
|
147
173
|
* **Themes.** Visitors choose from the palette button in the header. Their choice is
|
|
148
174
|
remembered in their browser. `default_theme` sets what a first-time visitor sees;
|
|
149
175
|
`system` follows their light or dark OS setting.
|
|
@@ -218,8 +218,37 @@ gh release create "v$v" "dist/texas2step-$v-1-amd64.deb" dist/texas2step-setup \
|
|
|
218
218
|
--title "texas2step $v" --generate-notes
|
|
219
219
|
```
|
|
220
220
|
|
|
221
|
-
6.
|
|
222
|
-
`uv tool install texas2step` and
|
|
221
|
+
6. Publishing the release publishes the Python package to
|
|
222
|
+
[PyPI](https://pypi.org/project/texas2step/) too, for `uv tool install texas2step` and
|
|
223
|
+
`pipx install texas2step`. `.github/workflows/pypi.yml` checks that the tag is
|
|
224
|
+
`v<version>`, runs the tests, builds the sdist and wheel from the tag, checks their
|
|
225
|
+
metadata, installs the wheel on its own and tests the server it starts. Then it uploads
|
|
226
|
+
both files with PyPI's trusted publishing, with no API token. Watch it, then check the
|
|
227
|
+
version's page on PyPI:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
gh run watch "$(gh run list --workflow pypi.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A version can be uploaded to PyPI only once. A run that fails before the upload can be
|
|
234
|
+
run again (`gh run rerun --failed <id>`). A fix that needs a new commit needs a new
|
|
235
|
+
version. To rehearse everything but the upload, run the workflow by hand: Actions →
|
|
236
|
+
pypi → Run workflow, or `gh workflow run pypi.yml`.
|
|
237
|
+
|
|
238
|
+
PyPI's copy of the README has its links made absolute, to GitHub, by
|
|
239
|
+
`hatch-fancy-pypi-readme` (`pyproject.toml`).
|
|
240
|
+
|
|
241
|
+
### Trusted publishing: the one-time setup
|
|
242
|
+
|
|
243
|
+
PyPI accepts the workflow's uploads because the project trusts it:
|
|
244
|
+
|
|
245
|
+
* On PyPI, under the project's **Settings → Publishing**, a GitHub trusted publisher
|
|
246
|
+
with the repository's owner and name, the workflow `pypi.yml` and the environment
|
|
247
|
+
`pypi`.
|
|
248
|
+
* On GitHub, under **Settings → Environments**, an environment named `pypi` whose
|
|
249
|
+
deployments are limited to tags matching `v*`, so only a release tag can publish.
|
|
250
|
+
|
|
251
|
+
Without the workflow, a maintainer with a PyPI API token can still publish by hand:
|
|
223
252
|
|
|
224
253
|
```bash
|
|
225
254
|
uv build --out-dir build/pypi
|
|
@@ -227,9 +256,6 @@ UV_PUBLISH_TOKEN=<token> uv publish build/pypi/*
|
|
|
227
256
|
rm -rf build/pypi
|
|
228
257
|
```
|
|
229
258
|
|
|
230
|
-
PyPI's copy of the README has its links made absolute, to GitHub, by
|
|
231
|
-
`hatch-fancy-pypi-readme` (`pyproject.toml`). A version can be uploaded to PyPI only once.
|
|
232
|
-
|
|
233
259
|
`texas2step update` finds the two files on the release by their names
|
|
234
260
|
(`texas2step-<version>-<revision>-<arch>.deb` and `texas2step-setup`) and checks each
|
|
235
261
|
against the SHA-256 GitHub records for it, so attach them under exactly those names. It
|
|
@@ -116,9 +116,10 @@ the score at 4.3, excellent; the rule rates it good.
|
|
|
116
116
|
### Calls over time
|
|
117
117
|
|
|
118
118
|
Monitor's call profiles send a call's packets for as long as the page runs, and score each
|
|
119
|
-
10 seconds as a call, so a problem that comes and goes shows
|
|
119
|
+
10 seconds as a call, so a problem that comes and goes shows in the scores of the 10
|
|
120
|
+
seconds it spoiled.
|
|
120
121
|
**Below good** is the share of the time a caller would have noticed something wrong
|
|
121
|
-
(below 4.0), and **Worst
|
|
122
|
+
(below 4.0), and **Worst 10s** the moment they'd remember. A video call also counts
|
|
122
123
|
**frames hit**: video frames with a packet lost or late, each one a freeze or a smear on
|
|
123
124
|
the screen. A video call keeps working with a few frames hit; its voice, scored beside
|
|
124
125
|
it, is what decides whether people can talk.
|
|
@@ -20,6 +20,17 @@ responsiveness), so their results are comparable.
|
|
|
20
20
|
| 4 | Upload, with latency under load and responsiveness | N parallel `POST /api/v1/up`, WebSocket probes, a new connection a second | 4 streams × 10 s |
|
|
21
21
|
| 5 | Packet loss, UDP RTT and jitter, call quality | WebRTC data channel, unordered, no retransmits | 500 probes of 200 bytes, 10 ms apart |
|
|
22
22
|
|
|
23
|
+
Each page keeps what it shows while another page is open, but only the page on screen
|
|
24
|
+
measures, so one page's traffic never skews another's figures. Going to another page
|
|
25
|
+
during a test stops it as Stop does, and its partial results stay.
|
|
26
|
+
|
|
27
|
+
While a test runs, and while Monitor or Diag is open, the page asks the browser to keep
|
|
28
|
+
the screen on (the Screen Wake Lock API). A phone whose screen goes off suspends the
|
|
29
|
+
page, and a locked computer slows its timers, so either would put gaps in the figures.
|
|
30
|
+
The **Keep screen on** switch, in Test options, Monitor's Options and on Diag, turns
|
|
31
|
+
this off for that browser. Browsers offer it only over HTTPS. They let the screen sleep
|
|
32
|
+
as usual when you switch to another app or tab, and some refuse it in battery saver.
|
|
33
|
+
|
|
23
34
|
## Latency
|
|
24
35
|
|
|
25
36
|
Each probe is a `GET /api/v1/ping` on an already-open keep-alive connection, so TCP and
|
|
@@ -275,21 +286,27 @@ whole. A frame goes out as a burst because a video call sends it that way, and b
|
|
|
275
286
|
what shallow buffers and policers drop.
|
|
276
287
|
|
|
277
288
|
With a call profile, the server [stamps](#packet-loss-udp) each echo, and every
|
|
278
|
-
10 seconds of the call is scored as the Test page scores its
|
|
279
|
-
direction on its own ([Call quality](#call-quality)):
|
|
280
|
-
|
|
281
|
-
* **
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
289
|
+
10 seconds of the call, counted from the start, is scored as the Test page scores its
|
|
290
|
+
packet-loss test, each direction on its own ([Call quality](#call-quality)):
|
|
291
|
+
|
|
292
|
+
* **On the loss-per-second strip**, each 10 seconds, once whole, gets a line beneath it
|
|
293
|
+
in its rating's colour and its score on a label over its middle, where there's room.
|
|
294
|
+
The seconds' own loss stays in view around and under them. Hovering a second gives its
|
|
295
|
+
loss and the score of its 10 seconds, each way.
|
|
296
|
+
* **Call quality**, the median 10-second score in the window; **Worst 10s**, and how
|
|
297
|
+
long ago; **Below good**, the share of the 10-second scores below good (4.0).
|
|
286
298
|
* **Frames hit** (Video call): video frames with a packet lost or more than 60 ms late,
|
|
287
299
|
in the worse direction. Each is a freeze or a smear on the screen.
|
|
288
300
|
|
|
289
301
|
Over hours the two computers' clocks drift apart, by tens of milliseconds an hour, so each
|
|
290
|
-
|
|
291
|
-
millisecond. A server without stamps scores each
|
|
292
|
-
fallback only the voice packets are sent.
|
|
302
|
+
10 seconds measures delays against its own fastest packet, where the drift is a fraction
|
|
303
|
+
of a millisecond. A server without stamps scores each 10 seconds on round trips, and over
|
|
304
|
+
the TCP fallback only the voice packets are sent.
|
|
305
|
+
|
|
306
|
+
**Stop** holds the chart and its figures where they stopped, to show or to take a
|
|
307
|
+
screenshot of. The window can still be changed, to look back over the last 15 minutes.
|
|
308
|
+
The 10 seconds cut short by Stop aren't scored. Going to another page stops the monitor
|
|
309
|
+
the same way, and the page says so when you come back to it.
|
|
293
310
|
|
|
294
311
|
Voice and Video calls count toward a [traffic limit](traffic.md#monitors-call-profiles).
|
|
295
312
|
When one is reached, the server holds the session to path probes and says so, and the
|
|
@@ -308,8 +325,9 @@ the client's own link, and both would read low.
|
|
|
308
325
|
|
|
309
326
|
## Diag
|
|
310
327
|
|
|
311
|
-
The Diag page tries each way of reaching the server once a second
|
|
312
|
-
|
|
328
|
+
The Diag page tries each way of reaching the server once a second while the page is
|
|
329
|
+
shown: HTTPS, WebSocket and UDP, over IPv4 and IPv6. Going to another page pauses it
|
|
330
|
+
and closes its connections. Its rows and events stay, and the timelines show the gap.
|
|
313
331
|
|
|
314
332
|
* **HTTPS** is a small request to `/api/v1/diag/http` on the server's IPv4-only or
|
|
315
333
|
IPv6-only address ([IPv4 and IPv6](configuration.md#ipv4-and-ipv6)). The answer gives
|
|
@@ -339,7 +357,7 @@ could see it, gives a random name ending in `.local` instead. So by default the
|
|
|
339
357
|
egress box shows only the addresses the server sees. Where the browser reveals it, the
|
|
340
358
|
box adds **This computer**, the address of the interface the UDP path leaves by, and a
|
|
341
359
|
**NAT** row comparing it with the address the server sees. Browser test runs save it
|
|
342
|
-
with their results
|
|
360
|
+
with their results (`client.local_ip`).
|
|
343
361
|
|
|
344
362
|
To have managed browsers reveal it to this site only:
|
|
345
363
|
|
|
@@ -102,6 +102,22 @@ links and footer credit. Three places still say texas2step:
|
|
|
102
102
|
|
|
103
103
|
Newest first. Each release's notes have the details and its upgrade steps.
|
|
104
104
|
|
|
105
|
+
### [2.2.0](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.2.0) (2026-10-24)
|
|
106
|
+
|
|
107
|
+
- [x] Each page keeps what it shows across tabs; only the page on screen measures.
|
|
108
|
+
- [x] The screen stays on while Monitor or Diag is open and while a test runs, with a
|
|
109
|
+
Keep screen on switch.
|
|
110
|
+
- [x] An organization's own footer: `brand_footer` and `[[brand_footer_links]]`.
|
|
111
|
+
- [x] Monitor's call tiles put their rating and "ago" under the score on narrow screens.
|
|
112
|
+
- [x] Releases publish to PyPI from GitHub, by trusted publishing.
|
|
113
|
+
|
|
114
|
+
### [2.1.1](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.1.1) (2026-10-24)
|
|
115
|
+
|
|
116
|
+
- [x] Monitor holds still at Stop, to show or take a screenshot of.
|
|
117
|
+
- [x] A call's scores sit on Monitor's loss-per-second strip, each over its 10 seconds.
|
|
118
|
+
- [x] History without the Local column; Diag's NAT and egress box shows this computer's
|
|
119
|
+
address.
|
|
120
|
+
|
|
105
121
|
### [2.1.0](https://github.com/robert-patrick-texas/texas2step/releases/tag/v2.1.0) (2026-10-24)
|
|
106
122
|
|
|
107
123
|
- [x] Call quality each way: a MOS from the ITU-T E-model for what you hear and what the
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import dataclasses
|
|
14
14
|
import ipaddress
|
|
15
15
|
import os
|
|
16
|
+
import re
|
|
16
17
|
import tomllib
|
|
17
18
|
import types
|
|
18
19
|
import typing
|
|
@@ -28,6 +29,8 @@ PAGES = ("test", "monitor", "diag", "history", "api") # the web UI's tabs, in o
|
|
|
28
29
|
THEMES = ("system", "light", "dark", "america", "energy", "federal", "military", "texas")
|
|
29
30
|
# Branding URLs end up in href attributes, so only these schemes are accepted.
|
|
30
31
|
LINK_SCHEMES = ("https://", "http://", "mailto:", "tel:")
|
|
32
|
+
# A link inside brand_footer's text, written as in Markdown: [label](url).
|
|
33
|
+
FOOTER_LINK = re.compile(r"\[([^\]\n]+)\]\(([^)\s]+)\)")
|
|
31
34
|
CREDITS = ("link", "text", "none") # brand_credit
|
|
32
35
|
TRAFFIC_COUNTS = ("sent", "received", "both")
|
|
33
36
|
MONITOR_PROFILES = ("path", "voice", "video") # monitor_profile
|
|
@@ -178,6 +181,10 @@ class Settings:
|
|
|
178
181
|
brand_logo: str = "" # an SVG, PNG, JPEG or WebP file shown in place of the star mark
|
|
179
182
|
brand_notice: str = "" # a line across the top of every page, e.g. an acceptable-use notice
|
|
180
183
|
brand_links: list[Link] = field(default_factory=list)
|
|
184
|
+
# The organization's own footer: its text, line by line, with [label](url) links in it,
|
|
185
|
+
# and its links beside the text.
|
|
186
|
+
brand_footer: str = ""
|
|
187
|
+
brand_footer_links: list[Link] = field(default_factory=list)
|
|
181
188
|
default_theme: str = "system" # one of THEMES; visitors can still pick another
|
|
182
189
|
# The footer's "texas2step <version>": linked to the project's site, plain text, or none.
|
|
183
190
|
brand_credit: str = "link"
|
|
@@ -259,10 +266,16 @@ class Settings:
|
|
|
259
266
|
urls = [("brand_url", self.brand_url)] if self.brand_url else []
|
|
260
267
|
if self.traffic_url:
|
|
261
268
|
urls.append(("traffic_url", self.traffic_url))
|
|
262
|
-
for
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
269
|
+
for name, links in (
|
|
270
|
+
("brand_links", self.brand_links),
|
|
271
|
+
("brand_footer_links", self.brand_footer_links),
|
|
272
|
+
):
|
|
273
|
+
for link in links:
|
|
274
|
+
if not link.label.strip():
|
|
275
|
+
raise ValueError(f"{name}: every link needs a label")
|
|
276
|
+
urls.append((f"{name} ({link.label})", link.url))
|
|
277
|
+
for label, url in FOOTER_LINK.findall(self.brand_footer):
|
|
278
|
+
urls.append((f"brand_footer ({label})", url))
|
|
266
279
|
for name, url in urls:
|
|
267
280
|
if not url.lower().startswith(LINK_SCHEMES):
|
|
268
281
|
raise ValueError(f"{name}: URL must start with {', '.join(LINK_SCHEMES)}")
|
|
@@ -356,7 +369,7 @@ def load_settings(config_path: str | os.PathLike[str] | None = None) -> Settings
|
|
|
356
369
|
|
|
357
370
|
for name, hint in hints.items():
|
|
358
371
|
env = os.environ.get(ENV_PREFIX + name.upper())
|
|
359
|
-
if env is not None and name not in {"locations", "brand_links"}:
|
|
372
|
+
if env is not None and name not in {"locations", "brand_links", "brand_footer_links"}:
|
|
360
373
|
values[name] = _coerce(env, hint, name)
|
|
361
374
|
|
|
362
375
|
return Settings(**values)
|
|
@@ -96,9 +96,11 @@ results_path = "/var/lib/texas2step/results.db"
|
|
|
96
96
|
# brand_url = "" # the organization's website
|
|
97
97
|
# brand_logo = "" # e.g. "/etc/texas2step/logo.svg" (SVG, PNG, JPEG, WebP)
|
|
98
98
|
# brand_notice = "" # a line across the top of every page
|
|
99
|
+
# brand_footer = "" # your footer's text: "\n" starts a line, [label](url) is a link
|
|
99
100
|
# default_theme = "system" # system, light, dark, america, energy, federal, military or texas
|
|
100
101
|
# brand_credit = "link" # footer's "texas2step <version>": link, text or none
|
|
101
|
-
# Header and footer links: see [[brand_links]] at the end of this file
|
|
102
|
+
# Header and footer links: see [[brand_links]] at the end of this file, and your footer's
|
|
103
|
+
# own links, beside its text, in [[brand_footer_links]].
|
|
102
104
|
|
|
103
105
|
# --- Other texas2step servers in the location picker ---------------------------------------
|
|
104
106
|
# [[locations]]
|
|
@@ -111,3 +113,8 @@ results_path = "/var/lib/texas2step/results.db"
|
|
|
111
113
|
# [[brand_links]]
|
|
112
114
|
# label = "Service desk"
|
|
113
115
|
# url = "https://help.example.net" # https://, http://, mailto: or tel:
|
|
116
|
+
|
|
117
|
+
# --- Your footer's links, beside brand_footer's text (TOML only) -----------------------------
|
|
118
|
+
# [[brand_footer_links]]
|
|
119
|
+
# label = "Privacy"
|
|
120
|
+
# url = "https://www.example.net/privacy"
|
|
@@ -6,7 +6,8 @@ fills in what only it knows:
|
|
|
6
6
|
- the path prefix it is published under (``<base href>``),
|
|
7
7
|
- the version (read by the UI from a meta tag),
|
|
8
8
|
- the site's default theme, and which pages it shows,
|
|
9
|
-
- the branding: name, logo, organization, links, notice
|
|
9
|
+
- the branding: name, logo, organization, links, notice, the organization's own footer
|
|
10
|
+
and the footer's credit.
|
|
10
11
|
|
|
11
12
|
Branding is written into the page itself rather than added by script, so a renamed site
|
|
12
13
|
never flashes the default name. Every value is HTML-escaped. Nothing from the request
|
|
@@ -21,7 +22,7 @@ import re
|
|
|
21
22
|
from html import escape
|
|
22
23
|
from pathlib import Path
|
|
23
24
|
|
|
24
|
-
from .config import PAGES, Settings
|
|
25
|
+
from .config import FOOTER_LINK, PAGES, Settings
|
|
25
26
|
|
|
26
27
|
log = logging.getLogger(__name__)
|
|
27
28
|
|
|
@@ -118,8 +119,33 @@ class Shell:
|
|
|
118
119
|
if footer:
|
|
119
120
|
dot = '<span class="dot" aria-hidden="true">·</span>'
|
|
120
121
|
page = _fill(page, "footer", dot.join(footer) + dot)
|
|
122
|
+
if own := self._own_footer():
|
|
123
|
+
page = _fill(page, "ownfooter", own)
|
|
121
124
|
return page
|
|
122
125
|
|
|
126
|
+
def _own_footer(self) -> str:
|
|
127
|
+
"""The organization's footer: its text on the left, its links on the right."""
|
|
128
|
+
s = self.settings
|
|
129
|
+
# A blank line starts a paragraph; each other line starts a line.
|
|
130
|
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", s.brand_footer.strip()) if p.strip()]
|
|
131
|
+
text = "".join(
|
|
132
|
+
"<p>" + "<br />".join(_inline(line.strip()) for line in p.splitlines()) + "</p>"
|
|
133
|
+
for p in paragraphs
|
|
134
|
+
)
|
|
135
|
+
links = "".join(f"<li>{_link(link.label, link.url)}</li>" for link in s.brand_footer_links)
|
|
136
|
+
parts = []
|
|
137
|
+
if text:
|
|
138
|
+
parts.append(f'<div class="own-footer-text">{text}</div>')
|
|
139
|
+
if links:
|
|
140
|
+
parts.append(
|
|
141
|
+
f'<nav class="own-footer-links" aria-label="Footer links"><ul>{links}</ul></nav>'
|
|
142
|
+
)
|
|
143
|
+
if not parts:
|
|
144
|
+
return ""
|
|
145
|
+
body = f'<div class="own-footer-body{" solo" if len(parts) == 1 else ""}">'
|
|
146
|
+
# The outer div is a row of its own, above the credit line.
|
|
147
|
+
return f'<div class="own-footer">{body}{"".join(parts)}</div></div>'
|
|
148
|
+
|
|
123
149
|
def _organization(self, cls: str = "") -> str:
|
|
124
150
|
s = self.settings
|
|
125
151
|
label = s.brand_organization or re.sub(r"^\w+:(//)?", "", s.brand_url).rstrip("/")
|
|
@@ -139,6 +165,15 @@ def _link(label: str, url: str, cls: str = "") -> str:
|
|
|
139
165
|
return f'<a{_class(cls)} href="{escape(url)}"{new_tab}>{escape(label)}</a>'
|
|
140
166
|
|
|
141
167
|
|
|
168
|
+
def _inline(text: str) -> str:
|
|
169
|
+
"""Plain text, with each [label](url) in it made a link."""
|
|
170
|
+
out, last = [], 0
|
|
171
|
+
for m in FOOTER_LINK.finditer(text):
|
|
172
|
+
out += [escape(text[last : m.start()]), _link(m.group(1), m.group(2))]
|
|
173
|
+
last = m.end()
|
|
174
|
+
return "".join([*out, escape(text[last:])])
|
|
175
|
+
|
|
176
|
+
|
|
142
177
|
def _fill(page: str, slot: str, html: str) -> str:
|
|
143
178
|
"""Put ``html`` in a slot: ``<!--t2s:slot-->`` or ``<!--t2s:slot-->…<!--/t2s:slot-->``."""
|
|
144
179
|
pattern = re.compile(rf"<!--t2s:{slot}-->(?:.*?<!--/t2s:{slot}-->)?", re.S)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root,[data-theme=light]{color-scheme:light;--page: #f3f2ee;--surface: #fdfdfc;--surface-2: #f6f5f1;--ink: #0e1013;--ink-2: #4a4f57;--muted: #646972;--grid: #e7e6e0;--axis: #c7c6be;--border: rgba(14, 16, 19, .08);--shadow: 0 1px 2px rgba(20, 22, 30, .04), 0 12px 32px -16px rgba(20, 22, 40, .18);--dl: #2a78d6;--ul: #eb6834;--idle: #1baf7a;--dl-from: #86b6ef;--ul-from: #f6b08e;--idle-from: #8fdcbf;--good-ink: #006300;--good: #0ca30c;--warning: #fab219;--serious: #ec835a;--critical: #d03b3b;--sun-1: #f5a524;--sun-2: #e8467c;--sun-3: #7c5cff;--brand: linear-gradient(120deg, var(--sun-1), var(--sun-2) 55%, var(--sun-3));--mark-1: var(--sun-1);--mark-2: var(--sun-2);--mark-3: var(--sun-3);--mark-star: #ffffff;--on-brand: #ffffff;--radius: 18px;--radius-sm: 10px;--radius-pill: 999px;--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-display: var(--font);--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;--ease: cubic-bezier(.2, .7, .2, 1);--notice: #0e1013;--notice-ink: #f3f2ee}@media(prefers-color-scheme:dark){:root:not([data-theme]){color-scheme:dark;--page: #090b0f;--surface: #121419;--surface-2: #0e1015;--ink: #eef0f3;--ink-2: #b4b9c2;--muted: #8a909a;--grid: #22252c;--axis: #353943;--border: rgba(255, 255, 255, .07);--shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 20px 40px -24px rgba(0, 0, 0, .8);--dl: #3987e5;--ul: #d95926;--idle: #199e70;--dl-from: #1c5cab;--ul-from: #8f3612;--idle-from: #0f5e43;--good-ink: #0ca30c;--sun-1: #f5a524;--sun-2: #e8467c;--sun-3: #7c5cff;--brand: linear-gradient(120deg, var(--sun-1), var(--sun-2) 55%, var(--sun-3));--mark-1: var(--sun-1);--mark-2: var(--sun-2);--mark-3: var(--sun-3);--mark-star: #ffffff;--notice: #1b1e25;--notice-ink: #d4d8df}}[data-theme=dark]{color-scheme:dark;--page: #090b0f;--surface: #121419;--surface-2: #0e1015;--ink: #eef0f3;--ink-2: #b4b9c2;--muted: #8a909a;--grid: #22252c;--axis: #353943;--border: rgba(255, 255, 255, .07);--shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 20px 40px -24px rgba(0, 0, 0, .8);--dl: #3987e5;--ul: #d95926;--idle: #199e70;--dl-from: #1c5cab;--ul-from: #8f3612;--idle-from: #0f5e43;--good-ink: #0ca30c;--sun-1: #f5a524;--sun-2: #e8467c;--sun-3: #7c5cff;--brand: linear-gradient(120deg, var(--sun-1), var(--sun-2) 55%, var(--sun-3));--mark-1: var(--sun-1);--mark-2: var(--sun-2);--mark-3: var(--sun-3);--mark-star: #ffffff;--notice: #1b1e25;--notice-ink: #d4d8df}[data-theme=federal]{color-scheme:light;--page: #eef1f6;--surface: #ffffff;--surface-2: #f4f6f9;--ink: #0d132d;--ink-2: #39415a;--muted: #646d82;--grid: #dfe4ec;--axis: #b6bfcf;--border: rgba(13, 19, 45, .13);--shadow: 0 1px 2px rgba(13, 19, 45, .06), 0 10px 26px -18px rgba(13, 19, 45, .35);--dl: #2a78d6;--ul: #eb6834;--idle: #1baf7a;--dl-from: #86b6ef;--ul-from: #f6b08e;--idle-from: #8fdcbf;--good-ink: #006300;--sun-1: #2f5bd0;--sun-2: #ffbd00;--sun-3: #0d132d;--brand: linear-gradient(140deg, #2a4fb3, #141f4d 62%, #0d132d);--mark-1: #2f5bd0;--mark-2: #1a2d74;--mark-3: #0d132d;--mark-star: #ffbd00;--on-brand: #ffffff;--radius: 6px;--radius-sm: 4px;--radius-pill: 4px;--font-display: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, Cambria, "Times New Roman", serif;--notice: #060a1a;--notice-ink: #d9dee8}[data-theme=texas]{color-scheme:dark;--page: #070b15;--surface: #0e1424;--surface-2: #0a101d;--ink: #f3f4f7;--ink-2: #bcc3d1;--muted: #8d95a8;--grid: #1d2537;--axis: #2f3a52;--border: rgba(255, 255, 255, .08);--shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 22px 44px -26px rgba(0, 0, 0, .9);--dl: #3987e5;--ul: #d95926;--idle: #199e70;--dl-from: #1c5cab;--ul-from: #8f3612;--idle-from: #0f5e43;--good-ink: #0ca30c;--sun-1: #bf0a30;--sun-2: #f05a28;--sun-3: #f6b73c;--brand: linear-gradient(120deg, var(--sun-1), var(--sun-2) 55%, var(--sun-3));--mark-1: #002868;--mark-2: #4b1f5c;--mark-3: #bf0a30;--mark-star: #ffffff;--on-brand: #ffffff;--radius: 12px;--radius-sm: 7px;--radius-pill: 8px;--font-display: var(--font);--notice: #0b1a3a;--notice-ink: #d2d8e4}[data-theme=america]{color-scheme:light;--page: #f2f5fa;--surface: #ffffff;--surface-2: #eef2f8;--ink: #0a1630;--ink-2: #33405c;--muted: #5f6b86;--grid: #e0e5ee;--axis: #bcc6d8;--border: rgba(10, 49, 97, .15);--shadow: 0 1px 2px rgba(10, 22, 48, .05), 0 12px 30px -18px rgba(10, 22, 48, .3);--dl: #2a78d6;--ul: #eb6834;--idle: #1baf7a;--dl-from: #86b6ef;--ul-from: #f6b08e;--idle-from: #8fdcbf;--good-ink: #006300;--sun-1: #b31942;--sun-2: #1d4c95;--sun-3: #0a3161;--brand: linear-gradient(125deg, #c8203f, #b31942 16%, #0a3161 58%, #163f80);--mark-1: #0a3161;--mark-2: #0a3161;--mark-3: #b31942;--mark-star: #ffffff;--on-brand: #ffffff;--radius: 10px;--radius-sm: 6px;--radius-pill: 6px;--font-display: var(--font);--notice: #0a3161;--notice-ink: #ffffff}[data-theme=energy]{color-scheme:dark;--page: #04080f;--surface: #0a121f;--surface-2: #070d18;--ink: #eaf2fa;--ink-2: #aabdd0;--muted: #8195aa;--grid: #18243a;--axis: #2b3c58;--border: rgba(122, 200, 255, .11);--shadow: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 22px 44px -26px rgba(0, 0, 0, .9);--dl: #3987e5;--ul: #d95926;--idle: #199e70;--dl-from: #1c5cab;--ul-from: #8f3612;--idle-from: #0f5e43;--good-ink: #0ca30c;--sun-1: #1fd2ff;--sun-2: #1fb8ff;--sun-3: #8f5cff;--brand: linear-gradient(120deg, #0fb5e6, #1f5fe0 50%, #7440f0);--mark-1: #1fd2ff;--mark-2: #1f5fe0;--mark-3: #7440f0;--mark-star: #ffffff;--on-brand: #ffffff;--radius: 8px;--radius-sm: 5px;--radius-pill: 6px;--font-display: var(--font);--notice: #06213a;--notice-ink: #c4e9ff}[data-theme=military]{color-scheme:light;--page: #e9e5d6;--surface: #faf8f1;--surface-2: #f2efe3;--ink: #1b1f14;--ink-2: #454a38;--muted: #5a5e4a;--grid: #d9d4c1;--axis: #b6b09a;--border: rgba(40, 45, 25, .16);--shadow: 0 1px 2px rgba(30, 34, 20, .07), 0 10px 26px -18px rgba(30, 34, 20, .4);--dl: #2a78d6;--ul: #eb6834;--idle: #1baf7a;--dl-from: #86b6ef;--ul-from: #f6b08e;--idle-from: #8fdcbf;--good-ink: #006300;--sun-1: #b09656;--sun-2: #56652b;--sun-3: #262c1b;--brand: linear-gradient(135deg, #6f7d3c, #4b5320 50%, #2c3320);--mark-1: #7d8c47;--mark-2: #4b5320;--mark-3: #2c3320;--mark-star: #ece3c4;--on-brand: #ffffff;--radius: 4px;--radius-sm: 3px;--radius-pill: 3px;--font-display: "DIN Condensed", Bahnschrift, "Ubuntu Condensed", "Roboto Condensed", "Arial Narrow", "DejaVu Sans Condensed", var(--font);--notice: #262c1b;--notice-ink: #ebe5cf}*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}body{zoom:var(--ui-zoom, 1);margin:0;min-height:100vh;background:var(--page);color:var(--ink);font:15px/1.5 var(--font);-webkit-font-smoothing:antialiased;display:flex;flex-direction:column}a{color:inherit}h1,h2,h3{margin:0;letter-spacing:-.015em;font-family:var(--font-display)}button{font:inherit;color:inherit}:focus-visible{outline:2px solid var(--dl);outline-offset:2px;border-radius:6px}.backdrop{position:fixed;inset:0;z-index:-1;pointer-events:none;background:radial-gradient(60vw 42vw at 8% -12%,color-mix(in srgb,var(--sun-1) 16%,transparent),transparent 70%),radial-gradient(50vw 36vw at 62% -18%,color-mix(in srgb,var(--sun-2) 11%,transparent),transparent 70%),radial-gradient(46vw 40vw at 100% -6%,color-mix(in srgb,var(--sun-3) 12%,transparent),transparent 70%),var(--page)}.backdrop:after{content:"";position:absolute;inset:0;opacity:.05;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")}.topbar{position:sticky;top:0;z-index:10;display:flex;align-items:center;gap:10px 24px;padding:12px max(16px,calc((100vw - 1240px)/2));-webkit-backdrop-filter:saturate(1.4) blur(14px);backdrop-filter:saturate(1.4) blur(14px);background:color-mix(in srgb,var(--page) 72%,transparent);border-bottom:1px solid var(--border)}.brand{display:flex;align-items:center;gap:10px;text-decoration:none;font-weight:650;font-size:18px;letter-spacing:-.02em}.brand-mark{width:30px;height:30px;filter:drop-shadow(0 4px 10px color-mix(in srgb,var(--sun-2) 35%,transparent))}.brand-word em{font-style:normal;background:var(--brand);-webkit-background-clip:text;background-clip:text;color:transparent;padding:0 1px}.tabs{display:flex;gap:4px}.tabs a{position:relative;padding:8px 12px;border-radius:var(--radius-pill);text-decoration:none;color:var(--ink-2);font-weight:550;font-size:14px;transition:color .2s,background .2s}.tabs a:hover{color:var(--ink);background:color-mix(in srgb,var(--ink) 5%,transparent)}.tabs a[aria-current]{color:var(--ink);background:var(--surface);box-shadow:0 0 0 1px var(--border),0 4px 12px -6px #0003}.topbar-actions{margin-left:auto;display:flex;align-items:center;gap:10px}.picker{display:flex;align-items:center;gap:8px;padding:6px 12px;border-radius:var(--radius-pill);background:var(--surface);box-shadow:0 0 0 1px var(--border);font-size:13px;max-width:320px}.picker-status{width:8px;height:8px;border-radius:50%;background:var(--good);box-shadow:0 0 0 3px color-mix(in srgb,var(--good) 22%,transparent);flex:none}.picker-status.down{background:var(--critical);box-shadow:0 0 0 3px color-mix(in srgb,var(--critical) 22%,transparent)}.picker-name{font-weight:600;white-space:nowrap}.picker-loc{color:var(--muted);white-space:nowrap}.picker-select{border:0;background:transparent;color:var(--ink);font:inherit;font-weight:600;max-width:260px;cursor:pointer}.picker-select option{background:var(--surface);color:var(--ink)}.icon-btn{width:36px;height:36px;display:grid;place-items:center;border:0;border-radius:50%;background:var(--surface);box-shadow:0 0 0 1px var(--border);cursor:pointer}.icon-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-btn svg path{fill:currentColor;fill-opacity:.12}.icon-btn svg circle{fill:currentColor;stroke:none}#view{width:100%;max-width:1240px;margin:0 auto;padding:20px 16px 36px;flex:1;outline:none}.footer{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;padding:24px 16px 32px;color:var(--muted);font-size:13px}.footer a{color:var(--ink-2)}.own-footer{flex:0 0 100%}.own-footer-body{max-width:1208px;margin:0 auto 12px;padding:20px 0 22px;border-top:1px solid var(--border);border-bottom:1px solid var(--border);display:grid;grid-template-columns:minmax(0,1fr) auto;gap:14px 56px;text-align:left;line-height:1.6}.own-footer-body.solo{grid-template-columns:minmax(0,1fr);justify-items:center;text-align:center}.own-footer-text p{max-width:80ch;margin:0 0 8px}.own-footer-text p:last-child{margin-bottom:0}.own-footer-links ul{display:grid;gap:6px;margin:0;padding:0;list-style:none}@media(max-width:640px){.own-footer-body{grid-template-columns:minmax(0,1fr)}}.bm-1{stop-color:var(--mark-1)}.bm-2{stop-color:var(--mark-2)}.bm-3{stop-color:var(--mark-3)}.bm-star{fill:var(--mark-star)}img.brand-mark{width:auto;max-width:180px;object-fit:contain;filter:none}.brand-org{margin-left:-10px;padding-left:14px;border-left:1px solid var(--border);color:var(--ink-2);font-size:13px;font-weight:550;white-space:nowrap;text-decoration:none}a.brand-org:hover,.topbar-links a:hover{color:var(--ink);text-decoration:underline;text-underline-offset:3px}.topbar-links{display:flex;gap:16px;font-size:13px;font-weight:550}.topbar-links a{color:var(--ink-2);text-decoration:none;white-space:nowrap}.notice{padding:7px max(16px,calc((100vw - 1240px)/2));background:var(--notice);color:var(--notice-ink);font-size:13px;line-height:1.45;text-align:center}.theme-menu{position:relative}.size-picker,.family-picker{padding:6px 6px 6px 12px}.picker-glyph{color:var(--muted);font-size:12px;font-weight:700;letter-spacing:-.02em}.theme-pop{position:absolute;top:calc(100% + 8px);right:0;z-index:30;min-width:228px;padding:6px;background:var(--surface);color:var(--ink);border-radius:var(--radius-sm);box-shadow:0 0 0 1px var(--border),0 22px 44px -18px #00000073}.theme-pop[hidden]{display:none}.theme-pop-title{padding:6px 10px;color:var(--muted);font-size:11px;font-weight:650;letter-spacing:.08em;text-transform:uppercase}.theme-item{display:flex;align-items:center;gap:12px;width:100%;padding:7px 10px;border:0;border-radius:calc(var(--radius-sm) - 2px);background:none;font-size:14px;text-align:left;cursor:pointer}.theme-item:hover,.theme-item:focus-visible{background:color-mix(in srgb,var(--ink) 7%,transparent);outline:none}.theme-item[aria-checked=true]{font-weight:650}.theme-check{width:16px;height:16px;margin-left:auto;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;opacity:0}.theme-item[aria-checked=true] .theme-check{opacity:1}.theme-swatch{position:relative;flex:none;width:36px;height:24px;border-radius:5px;background:var(--page);box-shadow:0 0 0 1px color-mix(in srgb,var(--ink) 18%,transparent);overflow:hidden}.theme-swatch:before{content:"";position:absolute;top:4px;left:4px;width:15px;height:8px;border-radius:2px;background:var(--surface);box-shadow:0 0 0 1px var(--border)}.theme-swatch:after{content:"";position:absolute;right:4px;bottom:4px;left:4px;height:5px;border-radius:3px;background:var(--brand)}.theme-swatch.split{display:grid;grid-template-columns:1fr 1fr}.theme-swatch.split:before,.theme-swatch.split:after{display:none}.theme-swatch.split>span{background:var(--page)}[data-theme=federal] .topbar{--surface: #182046;--ink: #ffffff;--ink-2: #c9d0e0;--muted: #9ba4bc;--border: rgba(255, 255, 255, .16);color-scheme:dark;background:#0d132d;color:var(--ink);border-bottom:3px solid var(--sun-2);-webkit-backdrop-filter:none;backdrop-filter:none}[data-theme=federal] .tabs a[aria-current]{background:transparent;box-shadow:inset 0 -2px 0 var(--sun-2)}[data-theme=federal] .brand-word em{background:none;color:var(--sun-2)}[data-theme=federal] .backdrop{background:linear-gradient(180deg,#dde3ee,var(--page) 520px)}[data-theme=texas] .topbar:after,[data-theme=energy] .topbar:after{content:"";position:absolute;right:0;left:0;pointer-events:none}[data-theme=texas] .topbar:after{bottom:-1px;height:2px;background:linear-gradient(90deg,#002868 0 33.3%,#fff 33.3% 66.6%,#bf0a30 66.6%)}[data-theme=texas] .tabs a[aria-current],[data-theme=america] .tabs a[aria-current],[data-theme=energy] .tabs a[aria-current]{box-shadow:0 0 0 1px var(--border),inset 0 -2px 0 var(--sun-1)}[data-theme=texas] .backdrop{background:radial-gradient(80vw 40vw at 88% -14%,rgba(0,40,104,.6),transparent 70%),radial-gradient(100vw 32vw at 50% 118%,color-mix(in srgb,var(--sun-2) 22%,transparent),transparent 72%),radial-gradient(60vw 26vw at 8% 112%,color-mix(in srgb,var(--sun-1) 20%,transparent),transparent 70%),var(--page)}[data-theme=america] .topbar{--ink-2: var(--ink);background:linear-gradient(105deg,color-mix(in srgb,#5b85c9 90%,transparent),color-mix(in srgb,#a6bde3 90%,transparent))}[data-theme=america] body:before{--edge: clamp(6px, (100vw - 1240px) / 2 - 10px, 12px);content:"";position:fixed;inset:0;z-index:30;pointer-events:none;background:linear-gradient(#0a3161,#0a3161) top / 100% 4px no-repeat,linear-gradient(#8f1435,#8f1435) left / var(--edge) 100% no-repeat,linear-gradient(#8f1435,#8f1435) right / var(--edge) 100% no-repeat}[data-theme=america] .backdrop{background:radial-gradient(55vw 34vw at 0% -12%,color-mix(in srgb,#0a3161 18%,transparent),transparent 70%),radial-gradient(55vw 34vw at 100% -12%,color-mix(in srgb,#1d4c95 12%,transparent),transparent 70%),var(--page)}[data-theme=america] .backdrop:before{content:"";position:absolute;inset:0;background:radial-gradient(circle,color-mix(in srgb,#0a3161 34%,transparent) 1.2px,transparent 1.8px) 0 0 / 24px 24px;-webkit-mask-image:radial-gradient(58vw 32vw at 0% 0%,#000,transparent 72%);mask-image:radial-gradient(58vw 32vw at 0% 0%,#000,transparent 72%)}[data-theme=energy] .topbar{background:color-mix(in srgb,#02050b 80%,transparent)}[data-theme=energy] .topbar:after{bottom:-1px;height:1px;background:linear-gradient(90deg,transparent,var(--sun-1) 25%,var(--sun-3) 75%,transparent);box-shadow:0 0 10px 1px color-mix(in srgb,var(--sun-1) 45%,transparent)}[data-theme=energy] .backdrop{background:radial-gradient(70vw 36vw at 30% -16%,color-mix(in srgb,var(--sun-1) 13%,transparent),transparent 70%),radial-gradient(56vw 34vw at 100% 112%,color-mix(in srgb,var(--sun-3) 15%,transparent),transparent 70%),var(--page)}[data-theme=energy] .backdrop:before{content:"";position:absolute;inset:0;background:linear-gradient(color-mix(in srgb,var(--sun-1) 9%,transparent) 1px,transparent 1px) 0 0 / 36px 36px,linear-gradient(90deg,color-mix(in srgb,var(--sun-1) 9%,transparent) 1px,transparent 1px) 0 0 / 36px 36px;-webkit-mask-image:radial-gradient(90vw 46vw at 30% 0%,#000,transparent 75%);mask-image:radial-gradient(90vw 46vw at 30% 0%,#000,transparent 75%)}[data-theme=military] .topbar{--surface: #353c26;--ink: #f3efe0;--ink-2: #d2ccb4;--muted: #a9a68c;--border: rgba(243, 239, 224, .16);color-scheme:dark;background:#232918;color:var(--ink);border-bottom:3px solid var(--sun-1);-webkit-backdrop-filter:none;backdrop-filter:none}[data-theme=military] .tabs a{font-size:13px;letter-spacing:.08em;text-transform:uppercase}[data-theme=military] .tabs a[aria-current]{background:transparent;box-shadow:inset 0 -2px 0 var(--sun-1)}[data-theme=military] .brand-word em{background:none;color:var(--sun-1)}[data-theme=military] :is(h1,h2,h3){font-stretch:condensed;letter-spacing:.01em;text-transform:uppercase}[data-theme=military] .backdrop{background:radial-gradient(64vw 40vw at 0% -10%,color-mix(in srgb,var(--sun-2) 16%,transparent),transparent 70%),var(--page)}[data-theme=military] .backdrop:before{content:"";position:absolute;inset:0;opacity:.12;background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='720' height='720'%3E%3Cfilter id='t' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.004' numOctaves='2' seed='4' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .29 0 0 0 0 .33 0 0 0 0 .13 2.2 0 0 0 -0.6'/%3E%3CfeComponentTransfer%3E%3CfeFuncA type='table' tableValues='0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23t)'/%3E%3C/svg%3E") 0 0 / 720px 720px}.card{background:var(--surface);border-radius:var(--radius);box-shadow:0 0 0 1px var(--border),var(--shadow);padding:18px 20px}.banner{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between;margin-bottom:20px;padding:12px 16px;border-radius:var(--radius-sm);background:color-mix(in srgb,var(--dl) 9%,var(--surface));box-shadow:0 0 0 1px color-mix(in srgb,var(--dl) 25%,transparent);font-size:14px}.banner[hidden]{display:none}.hero{display:grid;grid-template-columns:minmax(280px,400px) 1fr;gap:32px;align-items:center;margin-bottom:18px}.hero-head h1{font-size:clamp(24px,3vw,34px);line-height:1.12;font-weight:680;letter-spacing:-.03em;max-width:18em}.status{margin:10px 0 0;color:var(--ink-2);min-height:1.5em}.gauge{position:relative;width:100%;max-width:400px;aspect-ratio:1;margin:0 auto;--level: 0}.gauge-svg{width:100%;height:100%;overflow:visible}.gauge-ring{fill:none;stroke:var(--border)}.gauge-ring.ring-1{fill:color-mix(in srgb,var(--surface) 70%,transparent);stroke:var(--border)}.gauge-ring.ring-2{stroke-dasharray:2 6;stroke:var(--axis);opacity:.5;transform-origin:200px 200px}.gauge-ring.ring-3{stroke:var(--grid)}.gauge.running .ring-2{animation:spin 24s linear infinite;opacity:.9}.gauge-track{fill:none;stroke:var(--grid);stroke-width:14;stroke-linecap:round}.gauge-value{fill:none;stroke:url(#g-latency);stroke-width:14;stroke-linecap:round}.gauge-glow{fill:none;stroke:url(#g-latency);stroke-width:26;stroke-linecap:round;filter:url(#g-blur);opacity:calc(.25 + var(--level) * .55)}.gauge[data-tone=download] :is(.gauge-value,.gauge-glow){stroke:url(#g-download)}.gauge[data-tone=upload] :is(.gauge-value,.gauge-glow){stroke:url(#g-upload)}.gauge-tip{fill:var(--surface);stroke:var(--idle);stroke-width:3;opacity:0;transition:opacity .3s}.gauge[data-tone=download] .gauge-tip{stroke:var(--dl);opacity:1}.gauge[data-tone=upload] .gauge-tip{stroke:var(--ul);opacity:1}.gauge-ticks line{stroke:var(--axis);stroke-linecap:round}.gauge-ticks line.major{stroke:var(--muted);stroke-width:1.5}.gauge-label{fill:var(--muted);font-size:12px;font-weight:600;text-anchor:middle;dominant-baseline:middle}.gauge-readout{position:absolute;inset:30% 18% 26%;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;opacity:0;transform:scale(.96);transition:opacity .35s var(--ease),transform .35s var(--ease);pointer-events:none}.gauge.running .gauge-readout{opacity:1;transform:none}.gauge-caption{color:var(--ink-2);font-size:13px;font-weight:600;letter-spacing:.02em}.gauge-number{font-size:clamp(44px,7vw,68px);font-weight:650;letter-spacing:-.04em;line-height:1.05}.gauge-unit{color:var(--muted);font-size:14px;font-weight:600}.start-btn{position:absolute;left:50%;top:50%;width:46%;aspect-ratio:1;translate:-50% -50%;border:0;border-radius:50%;cursor:pointer;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;color:var(--on-brand);background:var(--brand);box-shadow:0 18px 50px -12px color-mix(in srgb,var(--sun-2) 55%,transparent),inset 0 1px #ffffff59;transition:transform .25s var(--ease),opacity .3s,box-shadow .25s}.start-btn:before{content:"";position:absolute;inset:-10px;border-radius:50%;border:2px solid color-mix(in srgb,var(--sun-2) 45%,transparent);animation:pulse 2.6s var(--ease) infinite}.start-btn:hover{transform:scale(1.03)}.start-btn:active{transform:scale(.98)}.start-label{font-size:clamp(26px,4vw,34px);font-weight:700;letter-spacing:-.02em}.start-sub{font-size:12px;font-weight:550;opacity:.85}.gauge.running .start-btn{top:79%;width:54px;font-size:0;background:var(--surface);color:var(--ink-2);box-shadow:0 0 0 1px var(--border),var(--shadow)}.gauge.running .start-btn:before{display:none}.gauge.running .start-btn:after{content:"";width:14px;height:14px;border-radius:3px;background:currentColor}.gauge.running .start-label,.gauge.running .start-sub{display:none}@keyframes pulse{0%{transform:scale(.94);opacity:.9}70%,to{transform:scale(1.18);opacity:0}}@keyframes spin{to{transform:rotate(360deg)}}.rail{list-style:none;margin:22px 0 18px;padding:0;display:grid;grid-template-columns:repeat(5,1fr);gap:8px}.rail-step{position:relative;display:grid;grid-template-columns:auto 1fr;grid-template-rows:auto auto;column-gap:8px;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--surface) 70%,transparent);box-shadow:0 0 0 1px var(--border);overflow:hidden}.rail-dot{grid-row:span 2;align-self:center;width:10px;height:10px;border-radius:50%;background:var(--grid);box-shadow:0 0 0 3px transparent;transition:background .3s,box-shadow .3s}.rail-name{font-size:13px;font-weight:600}.rail-value{font-size:12px;color:var(--ink-2);min-height:1.4em;white-space:nowrap}.rail-step[data-state=active] .rail-dot{background:var(--sun-2);box-shadow:0 0 0 4px color-mix(in srgb,var(--sun-2) 25%,transparent);animation:blink 1.2s ease-in-out infinite}.rail-step[data-state=active]:after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--brand);animation:sweep 1.6s linear infinite;transform-origin:left}.rail-step[data-state=done] .rail-dot{position:relative;background:none}.rail-step[data-state=done] .rail-dot:after{content:"";position:absolute;left:3px;top:-1px;width:4px;height:9px;border:solid var(--good);border-width:0 2px 2px 0;transform:rotate(45deg)}.rail-step[data-state=skipped]{opacity:.5}@keyframes blink{50%{opacity:.45}}@keyframes sweep{0%{transform:scaleX(0)}to{transform:scaleX(1)}}.hero-set{min-width:0;margin:14px 0 0;padding:2px 10px 10px;border:1px solid var(--border);border-radius:var(--radius)}.hero-set+.hero-set{margin-top:10px}.hero-set legend{padding:0 6px;color:var(--muted);font-size:11.5px;font-weight:650;letter-spacing:.06em;text-transform:uppercase}.hero-set .rail{margin:0}.tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.tiles.six{grid-template-columns:repeat(6,1fr);margin-bottom:20px}.tiles[hidden]{display:none}.tile{padding:11px 15px;border-radius:14px;background:var(--surface);box-shadow:0 0 0 1px var(--border),var(--shadow);min-width:0}.tile-label{display:flex;align-items:center;gap:7px;color:var(--ink-2);font-size:13px;font-weight:550}.tile-figure{display:flex;align-items:baseline;gap:6px;margin-top:4px}.tile-value{font-size:clamp(24px,2.6vw,32px);font-weight:650;letter-spacing:-.03em;line-height:1.1;white-space:nowrap}.tile.compact .tile-value{font-size:22px}.tile-count{align-self:center;color:var(--muted);font-size:14px;font-weight:650;font-variant-numeric:tabular-nums;white-space:nowrap}.tile.compact .tile-figure{flex-wrap:wrap;row-gap:0}.tile-count.bad{color:var(--critical)}.tile-unit{color:var(--muted);font-size:13px;font-weight:600}.tile-sub{margin-top:4px;color:var(--muted);font-size:12px;min-height:1.5em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.grade{display:inline-flex;align-items:center;padding:0 7px;border-radius:var(--radius-pill);font-weight:650;color:var(--ink);background:color-mix(in srgb,var(--good) 18%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--good) 45%,transparent)}.grade[data-grade=B]{background:color-mix(in srgb,var(--warning) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warning) 55%,transparent)}.grade[data-grade=C],.grade[data-grade=D]{background:color-mix(in srgb,var(--serious) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--serious) 55%,transparent)}.grade[data-grade=F]{background:color-mix(in srgb,var(--critical) 20%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--critical) 55%,transparent)}.grade[data-rating=fair]{background:color-mix(in srgb,var(--warning) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--warning) 55%,transparent)}.grade[data-rating=poor]{background:color-mix(in srgb,var(--serious) 22%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--serious) 55%,transparent)}.grade[data-rating=bad]{background:color-mix(in srgb,var(--critical) 20%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--critical) 55%,transparent)}.options{margin-top:14px}.options summary{cursor:pointer;color:var(--ink-2);font-size:13px;font-weight:600;width:fit-content;list-style:none;display:flex;align-items:center;gap:6px}.options summary::-webkit-details-marker{display:none}.options summary:before{content:"";width:7px;height:7px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:rotate(-45deg);transition:transform .2s}.options[open] summary:before{transform:rotate(45deg)}.opt-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 20px;margin-top:12px}.opt{display:flex;flex-direction:column;gap:6px}.opt-label{font-size:12px;color:var(--muted);font-weight:600}.seg{display:inline-flex;padding:3px;border-radius:10px;background:color-mix(in srgb,var(--ink) 5%,transparent);width:fit-content;flex-wrap:wrap}.seg button{border:0;background:transparent;padding:5px 11px;border-radius:7px;font-size:13px;font-weight:550;color:var(--ink-2);cursor:pointer}.seg button[aria-checked=true]{background:var(--surface);color:var(--ink);box-shadow:0 0 0 1px var(--border),0 2px 6px -2px #0003}.link{display:grid;grid-template-columns:minmax(120px,300px) 1fr minmax(120px,300px);align-items:center;gap:16px;padding:18px 22px;margin-bottom:24px}.node{min-width:0}.node-server{text-align:right}.node-icon{width:40px;height:40px;display:grid;place-items:center;border-radius:12px;background:var(--surface-2);box-shadow:0 0 0 1px var(--border);color:var(--ink-2);margin-bottom:8px}.node-server .node-icon{margin-left:auto}.node-icon svg{width:22px;height:22px}.node-title{font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.node-detail{color:var(--muted);font-size:12.5px;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.link-wire{position:relative;height:96px}.link-canvas{width:100%;height:100%;display:block}.lane-label{position:absolute;font-size:11px;color:var(--muted);left:50%;translate:-50% 0;white-space:nowrap}.lane-down{top:2px}.lane-up{bottom:2px}.link-center{position:absolute;inset:0;display:grid;place-items:center;font-weight:650;font-size:14px;pointer-events:none;text-shadow:0 0 12px var(--surface),0 0 4px var(--surface)}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.span-2{grid-column:span 2}.card-head{margin-bottom:10px}.card-head h2{font-size:17px;font-weight:650}.card-head p{margin:4px 0 0;color:var(--muted);font-size:13px;max-width:70ch}.chart-plot{width:100%;min-height:60px}.chart-svg{display:block;overflow:visible;font-family:var(--font)}.chart-svg .grid{stroke:var(--grid);stroke-width:1;shape-rendering:crispEdges}.chart-svg .baseline{stroke:var(--axis);stroke-width:1;shape-rendering:crispEdges}.chart-svg .tick{fill:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}.chart-svg .row-label{fill:var(--ink-2);font-size:12.5px;font-weight:550}.chart-svg .direct-label,.chart-svg .bar-label{fill:var(--ink);font-size:12px;font-weight:600;paint-order:stroke;stroke:var(--surface);stroke-width:3px;stroke-linejoin:round}.chart-svg .bar-label{fill:var(--ink-2);font-weight:550}.chart-svg .annot,.chart-svg .empty{fill:var(--muted);font-size:12px}.chart-svg .warmup{fill:color-mix(in srgb,var(--ink) 3.5%,transparent)}.chart-svg .area{opacity:.1}.chart-svg .line{fill:none;stroke-width:2;stroke-linejoin:round;stroke-linecap:round}.chart-svg .line.dashed{stroke-dasharray:5 4}.chart-svg .ref{stroke-width:1;opacity:.75}.chart-svg .dot{stroke:var(--surface);stroke-width:2}.chart-svg .iqr{opacity:.14}.chart-svg .median{stroke-width:2.5;stroke-linecap:round}.chart-svg .placeholder{stroke:var(--grid);stroke-dasharray:none}.chart-svg .crosshair{stroke:var(--axis);stroke-width:1}.chart-svg .hit{fill:transparent}.chart-svg .bar:hover{filter:brightness(1.12)}.legend{display:flex;flex-wrap:wrap;gap:4px 16px;margin-bottom:10px;font-size:12.5px;color:var(--ink-2)}.legend-item{display:inline-flex;align-items:center;gap:6px}.legend-key{display:inline-block;flex:none;background:var(--key)}.legend-key.line{width:14px;height:2.5px;border-radius:2px}.legend-key.dash{width:14px;height:2.5px;background:repeating-linear-gradient(90deg,var(--key) 0 4px,transparent 4px 7px)}.legend-key.square{width:10px;height:10px;border-radius:3px}.legend-key.dot{width:8px;height:8px;border-radius:50%}.fairness{display:flex;flex-wrap:wrap;gap:6px 18px;margin-top:10px;font-size:12.5px;color:var(--ink-2)}.fair-item{display:inline-flex;align-items:center;gap:6px}.fair-item strong{color:var(--ink)}.packet-body{display:grid;grid-template-columns:minmax(0,1fr);gap:16px}.packet-canvas{display:block;max-width:100%}.packet-scale{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-top:10px;font-size:12px;color:var(--ink-2)}.scale-bar{width:90px;height:8px;border-radius:4px}.scale-gap{width:10px}.stats-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2px 18px;margin:0;font-size:13px}.stats-list dt{color:var(--muted);grid-column:1}.stats-list dd{margin:0;grid-column:2;font-weight:600;font-variant-numeric:tabular-nums}.chart-note{margin:10px 0 0;color:var(--ink-2);font-size:12.5px}.chart-note[hidden]{display:none}.stats-list .note{grid-column:1 / -1;margin:8px 0 0;color:var(--ink-2);font-size:12.5px}.readiness{list-style:none;margin:0;padding:0;display:grid;gap:8px}.ready-row{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:10px 12px;border-radius:12px;background:var(--surface-2);box-shadow:0 0 0 1px var(--border)}.ready-text{display:flex;flex-direction:column;min-width:0}.ready-text strong{font-size:14px}.ready-text span{color:var(--muted);font-size:12.5px}.glyph{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.glyph.use{color:var(--ink-2)}.glyph.lvl{width:14px;height:14px;stroke-width:2.6}.level{display:inline-flex;align-items:center;gap:5px;padding:3px 10px 3px 7px;border-radius:var(--radius-pill);font-size:12.5px;font-weight:650;--tone: var(--muted);background:color-mix(in srgb,var(--tone) 16%,transparent);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--tone) 45%,transparent)}.level .glyph{color:var(--tone)}.level[data-level=great],.level[data-level=good]{--tone: var(--good)}.level[data-level=fair]{--tone: var(--warning)}.level[data-level=poor]{--tone: var(--critical)}.empty-note{color:var(--muted);font-size:13px}.table{width:100%;border-collapse:collapse;font-size:13.5px}.table th,.table td{padding:9px 10px;text-align:left;border-bottom:1px solid var(--grid);vertical-align:top}.table tbody th{width:190px;color:var(--ink-2);font-weight:550}.details .table td{overflow-wrap:anywhere}.table.data thead th{color:var(--muted);font-size:12px;font-weight:600}.table .num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}.table .mono{font-family:var(--mono);font-size:12.5px}.table .cell-sub{display:block;color:var(--muted)}.table .when{white-space:nowrap}.table .addr{overflow-wrap:anywhere}.table tbody tr:hover td{background:color-mix(in srgb,var(--ink) 2.5%,transparent)}.table-wrap{overflow-x:auto}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:16px}.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:0;border-radius:10px;background:var(--surface);box-shadow:0 0 0 1px var(--border),0 2px 6px -3px #0003;font-size:13.5px;font-weight:600;text-decoration:none;cursor:pointer;white-space:nowrap}.btn:hover{background:var(--surface-2)}.btn.primary{color:var(--on-brand);background:var(--brand);box-shadow:0 6px 18px -8px color-mix(in srgb,var(--sun-2) 70%,transparent)}.btn.big{padding:11px 20px;font-size:15px}.btn.small{padding:5px 10px;font-size:13px}.btn:disabled{opacity:.6;cursor:progress}.page-head{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-end;justify-content:space-between;margin-bottom:16px}.page-head h1{font-size:clamp(24px,2.6vw,30px);font-weight:680;letter-spacing:-.03em}.lede{margin:4px 0 0;color:var(--ink-2);max-width:88ch}.monitor-notice{margin:6px 0 0;max-width:88ch;color:var(--ink);font-weight:550}.monitor-notice[hidden]{display:none}.head-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.chip{padding:5px 10px;border-radius:var(--radius-pill);font-size:12.5px;font-weight:600;color:var(--ink-2);background:var(--surface);box-shadow:0 0 0 1px var(--border)}.toolbar{display:flex;flex-wrap:wrap;gap:12px 28px;margin-bottom:14px}.live-canvas,.loss-strip{display:block;width:100%}.strip-label{display:flex;align-items:center;gap:6px;margin:10px 0 4px 46px;font-size:12px;color:var(--muted)}.dest-box{display:contents}.options.monitor-sizes{align-self:flex-end;margin:0 0 9px}.options.monitor-sizes[open]{flex-basis:100%;margin-bottom:0}.seg.seg-feature{background:color-mix(in srgb,var(--sun-2) 10%,transparent);box-shadow:0 0 0 1.5px color-mix(in srgb,var(--sun-2) 55%,transparent)}.seg.seg-feature.invite{animation:invite 1.6s var(--ease) .6s 2}@keyframes invite{50%{box-shadow:0 0 0 1.5px color-mix(in srgb,var(--sun-2) 55%,transparent),0 0 0 7px color-mix(in srgb,var(--sun-2) 18%,transparent)}}.seg-dots{display:inline-flex;gap:3px;margin-left:6px;vertical-align:1px}.servers-opt .opt-label{color:var(--ink-2);font-weight:650}.card-head.head-inline{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 14px}.card-head.head-inline p{margin:0}.card-head.head-inline .chip{margin-left:auto;align-self:center}.loss-strip+.loss-strip{margin-top:4px}.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.gated .tabs{display:none}.gated .server-picker,.gated #family-picker{visibility:hidden}.gate{display:grid;place-items:center;padding:clamp(16px,8vh,72px) 0}.gate-card{width:min(100%,460px);padding:26px 28px}.gate-card h1{font-size:clamp(22px,2.4vw,26px);font-weight:680;letter-spacing:-.02em}.gate-form{display:flex;gap:10px;margin-top:18px}.gate-form .btn{justify-content:center}.code-input{flex:1;min-width:0;padding:10px 14px;border:0;border-radius:10px;background:var(--surface-2);box-shadow:inset 0 0 0 1px var(--border);color:var(--ink);font:inherit;font-size:17px;letter-spacing:.02em}.code-input:focus{outline:2px solid var(--dl);outline-offset:1px}.code-input.small{flex:none;width:9.5em;padding:5px 10px;font-size:13.5px}.gate-error{min-height:1.4em;margin:10px 0 0;color:var(--ul);font-weight:600}.gate-note{margin:2px 0 0;color:var(--muted);font-size:13.5px}.result-id{align-self:center;margin:0;color:var(--ink-2)}.result-id strong,.desk-code strong{font-family:var(--mono);font-size:1.08em;color:var(--ink);letter-spacing:.02em}.desk-tools{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center}.desk-code{margin:0;color:var(--ink-2)}.desk-open{display:flex;gap:6px}@media(max-width:600px){.gate-card{padding:20px 18px}.gate-form{flex-direction:column}}.diag-card{margin-bottom:20px}.diag-addresses{display:grid;gap:6px}.diag-you{display:flex;flex-wrap:wrap;gap:4px 40px}.diag-addr{display:flex;align-items:baseline;gap:10px;min-width:0}.diag-fam{color:var(--muted);font-size:13px;font-weight:600;white-space:nowrap}.diag-ip{font-family:var(--mono);font-size:clamp(16px,calc((100vw - 170px) / 24),26px);font-weight:650;overflow-wrap:anywhere}.diag-ip.none{font-family:var(--font);font-size:15px;font-weight:500;color:var(--muted)}.diag-server{color:var(--ink-2);font-family:var(--mono);font-size:15px;overflow-wrap:anywhere}.diag-clock{color:var(--ink-2);font-size:13px}.lede.wide,.card-head p.wide{max-width:none}.diag-row{display:grid;grid-template-columns:150px minmax(120px,1fr) minmax(240px,1.5fr) minmax(150px,300px);align-items:center;gap:14px;padding:7px 2px;border-bottom:1px solid var(--grid)}.diag-row:last-child{border-bottom:0}.diag-row[data-tone=off]{opacity:.65}.diag-what{display:flex;flex-wrap:wrap;align-items:baseline;gap:0 8px}.diag-what strong{font-weight:650}.diag-port{color:var(--muted);font-family:var(--mono);font-size:12px}.lane,.diag-timeline{display:block;width:100%}.diag-badge{margin-right:8px;font-size:13.5px;font-weight:650;color:var(--muted)}.diag-row[data-tone=up] .diag-badge{color:var(--good-ink)}.diag-row[data-tone=loss] .diag-badge{color:var(--critical)}.diag-figs{color:var(--ink-2);font-size:12.5px;font-variant-numeric:tabular-nums}.diag-detail{color:var(--ink-2);font-size:12px;overflow-wrap:anywhere}.diag-note{margin:12px 0 0;color:var(--ink-2);font-size:12.5px}.egress{display:grid;gap:14px}.egress-fam h3{margin:0 0 4px;font-size:14px;font-weight:650}.egress-fam dl{display:grid;grid-template-columns:auto 1fr;gap:3px 16px;margin:0;font-size:13px}.egress-fam dt{color:var(--muted)}.egress-fam dd{margin:0;overflow-wrap:anywhere}.egress-note{display:block;color:var(--muted);font-size:12px}.diag-log-head{justify-content:space-between}.diag-log{max-height:260px;margin:0;padding:0;overflow:auto;list-style:none;font-size:12.5px}.diag-log li{display:flex;gap:10px;padding:3px 0;border-bottom:1px solid var(--grid)}.diag-log time{color:var(--muted);font-family:var(--mono);font-variant-numeric:tabular-nums}.diag-log .none{color:var(--muted)}@media(max-width:980px){.diag-row{grid-template-columns:1fr 1.6fr}.diag-row .lane,.diag-timeline{grid-column:1 / -1}}@media(max-width:600px){.diag-row{grid-template-columns:1fr;gap:6px}.diag-addr{flex-direction:column;align-items:flex-start;gap:0}.diag-ip.v6:not(.none){font-size:clamp(11px,calc((100vw - 72px) / 24),18px)}.diag-ip.v4:not(.none){font-size:clamp(18px,calc((100vw - 72px) / 11),24px)}.diag-server{font-size:clamp(10.5px,calc((100vw - 72px) / 25.5),15px)}}.opt-label .legend-key{vertical-align:1px}.dest-select{max-width:260px;padding:6px 10px;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 5%,transparent);color:var(--ink);font:inherit;font-size:13px;font-weight:550;cursor:pointer}.dest-select option{background:var(--surface);color:var(--ink)}.dest-select:disabled,.seg button:disabled{cursor:default;opacity:.6}.compare-wrap{margin-bottom:12px}.table.compare th,.table.compare td{padding:3px 10px}.table.compare thead th{padding-bottom:6px}.table.compare thead th{vertical-align:bottom;font-size:13px;font-weight:650}.table.compare thead th:last-child{color:var(--muted);font-weight:600}.table.compare tbody td{font-size:14px;font-weight:600}.table.compare tbody td:last-child{color:var(--ink-2);font-weight:550}.table.compare td.bad{color:var(--critical)}.compare-name{display:inline-flex;align-items:center;gap:6px}.compare-sub{margin-left:8px;color:var(--muted);font-size:12px;font-weight:500}.empty-card{display:flex;flex-direction:column;align-items:center;gap:12px;padding:48px;color:var(--ink-2)}.chart-card+.card,.card+.card{margin-top:0}.page>.card,.page>div>.card{margin-bottom:20px}.cli-card{margin-bottom:28px}.cli-card h2,.api-group h2{font-size:19px;font-weight:650}.cli-card p{color:var(--ink-2);margin:6px 0 14px}.api-group{margin-bottom:32px}.api-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:14px;margin-top:14px}.api-item{display:flex;flex-direction:column;gap:6px;padding:18px}.api-item h3{font-size:15px}.api-item p{margin:0 0 6px;color:var(--ink-2);font-size:13.5px;flex:1}.api-sig{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:13px;min-width:0}.api-sig code{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.method{padding:2px 7px;border-radius:6px;font-size:11px;font-weight:700;letter-spacing:.04em;color:var(--ink);background:color-mix(in srgb,var(--idle) 22%,transparent)}.method.m-post{background:color-mix(in srgb,var(--ul) 22%,transparent)}.method.m-ws{background:color-mix(in srgb,var(--dl) 22%,transparent)}.code{position:relative;border-radius:10px;background:var(--surface-2);box-shadow:inset 0 0 0 1px var(--border)}.code pre{margin:0;padding:12px 64px 12px 14px;overflow-x:auto;font:12.5px/1.6 var(--mono);color:var(--ink-2)}.api-item .code pre{white-space:pre-wrap;word-break:break-all}.code .copy{position:absolute;top:8px;right:8px;border:0;padding:3px 8px;border-radius:6px;font-size:12px;background:var(--surface);box-shadow:0 0 0 1px var(--border);cursor:pointer}.tooltip{position:fixed;left:0;top:0;z-index:50;pointer-events:none;min-width:140px;padding:8px 10px;border-radius:10px;background:color-mix(in srgb,var(--surface) 94%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);box-shadow:0 0 0 1px var(--border),0 12px 28px -10px #00000059;font-size:12.5px}.tooltip[hidden]{display:none}.tip-title{color:var(--muted);font-size:11.5px;margin-bottom:4px}.tip-row{display:flex;align-items:center;gap:7px;white-space:nowrap}.tip-row strong{font-variant-numeric:tabular-nums}.tip-row span:last-child{color:var(--ink-2)}.tip-key{flex:none;background:var(--key)}.tip-key.line{width:12px;height:2.5px;border-radius:2px}.tip-key.dot{width:8px;height:8px;border-radius:50%}.tip-key.square{width:9px;height:9px;border-radius:2px}.tip-key.none{width:0}.noscript{text-align:center;padding:40px}@media(max-width:980px){.hero{grid-template-columns:1fr;gap:16px}.hero-gauge{max-width:400px;width:100%;margin:0 auto}.tiles.six{grid-template-columns:repeat(3,1fr)}.tile[data-key=mos] .tile-count,.tile[data-key=worst] .tile-count{flex-basis:100%}}@media(max-width:1180px){.topbar-links{display:none}}.topbar.wrapped{flex-wrap:wrap}@media(max-width:600px){#size-picker{display:none}.table.compare th,.table.compare td{padding:5px 6px;font-size:13px}.table.compare thead th{white-space:normal}.compare-sub{display:none}.table .addr{display:inline-block;width:20ch}.details .table,.details .table tbody,.details .table tr{display:block}.details .table tr{padding:8px 0;border-bottom:1px solid var(--grid)}.details .table th,.details .table td{display:block;width:auto;padding:0;border:0}}@media(max-width:760px){.topbar{flex-wrap:wrap;gap:10px 16px}.brand-org{display:none}.tabs{order:3;width:100%;overflow-x:auto}.topbar-actions{flex:1 1 0;min-width:0;justify-content:flex-end}#server-picker,#server-picker .picker,#server-picker .picker>*{min-width:0}#server-picker .picker-name,#server-picker .picker-select{overflow:hidden;text-overflow:ellipsis}.family-picker .picker-select{field-sizing:content}.grid{grid-template-columns:minmax(0,1fr)}.span-2{grid-column:auto}.tiles,.rail{grid-template-columns:repeat(2,1fr)}.hero-set{margin-top:22px;padding:0;border:0}.hero-set+.hero-set{margin-top:18px}.hero-set legend{position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.link{grid-template-columns:1fr 1fr;padding:16px}.link-wire{grid-column:1 / -1;grid-row:2}.node-detail{white-space:normal;overflow-wrap:anywhere}.card{padding:16px}.picker-loc{display:none}.table tbody th{width:auto}.stats-list{grid-template-columns:auto 1fr}}@media(max-width:420px){.tiles.six{grid-template-columns:repeat(2,1fr)}.picker{max-width:180px}.picker-select{max-width:130px}.topbar:has(#family-picker .picker) .brand-word{display:none}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}@media(forced-colors:active){.gauge-value,.chart-svg .line{stroke:CanvasText}}
|