engine: downstream MTU and downstream train in the measurement document
Three facts the client cannot produce alone, kept deliberately separate: mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only because the server sets DF), mtu.frag_delivery (whether larger ones arrive once fragmentation is allowed), and train.udp_downstream (loss, reordering and arrival spacing in the download direction, which a round trip cannot separate from upstream loss). ServerMeasurement now runs them on the same ProbeSession as the echo train. It had to: a fresh session restarts client-side sequence numbers and the server's anti-replay window discards the lot, so the re-primed source is never recorded and every granted send goes to a socket that has already closed. That produced four confidently-wrong FAILED tests and a RED verdict on a healthy network. Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ce1aaa332a
commit
14e5fad1b2
@@ -0,0 +1,74 @@
|
||||
# Echolot website (`web/`)
|
||||
|
||||
Minimal single-page site for [echo-lot.app](https://echo-lot.app), served from Cloudflare
|
||||
Workers. Static files in `public/` are served straight from the edge; the tiny Worker in
|
||||
`src/index.js` only runs for paths that aren't files:
|
||||
|
||||
| Path | Behavior |
|
||||
| ------------- | ------------------------------------------------------------------------ |
|
||||
| `/apk` | 302 → newest `.apk` asset of the latest Gitea release (QR-code friendly) |
|
||||
| `/apk.sha256` | 302 → the matching `.sha256` asset |
|
||||
| `/api/latest` | JSON `{version, published_at, apk, sha256}` — the homepage's version readout |
|
||||
| `/fdroid`, `/source` | 302 → the URLs configured in `wrangler.jsonc` vars |
|
||||
|
||||
The latest release is resolved from the Gitea API **at request time** (edge-cached 5 min), so
|
||||
publishing a release — `git tag v0.2.0 && git push origin v0.2.0`, which triggers
|
||||
`.gitea/workflows/release.yml` — is the only release step. The site never needs a redeploy for
|
||||
a new version, and empty/unreachable values fall back to the homepage instead of 404ing.
|
||||
|
||||
Light/dark follows the OS (`prefers-color-scheme`), no toggle, no JS required for it. Colors
|
||||
come from the branding palette (teal = instrument, single amber point = finding).
|
||||
|
||||
`public/assets/` (favicon, wordmark, social preview) are **copies** of `../assets/branding/` —
|
||||
that directory is the source of truth; re-copy after any branding change.
|
||||
|
||||
## Deploy
|
||||
|
||||
Everything is driven by [wrangler](https://developers.cloudflare.com/workers/wrangler/), config
|
||||
in `wrangler.jsonc`. No build step, no node_modules to commit.
|
||||
|
||||
### One-time setup
|
||||
|
||||
1. In the Cloudflare dashboard, add **echo-lot.app** as a zone (and point the domain's
|
||||
nameservers at Cloudflare). The `routes` in `wrangler.jsonc` use `custom_domain: true`, so
|
||||
wrangler creates the DNS records for `echo-lot.app` and `www` automatically on first deploy —
|
||||
the zone just has to exist in the same account.
|
||||
2. Auth, either flavor:
|
||||
- **Interactive:** `npx wrangler login` (opens the browser once, stores an OAuth token).
|
||||
- **API token (also what CI uses):** dashboard → My Profile → API Tokens → create from the
|
||||
**"Edit Cloudflare Workers"** template. Then:
|
||||
|
||||
```
|
||||
$env:CLOUDFLARE_API_TOKEN = "..." # PowerShell; export ... on POSIX
|
||||
$env:CLOUDFLARE_ACCOUNT_ID = "..." # dashboard → Workers & Pages, right sidebar
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
||||
```
|
||||
cd web
|
||||
npx wrangler@4 deploy
|
||||
```
|
||||
|
||||
That's it — uploads `src/index.js` + the `public/` assets, wires the custom domains. Useful
|
||||
extras: `npx wrangler dev` (local preview at localhost:8787), `npx wrangler tail` (live logs),
|
||||
`npx wrangler versions list`.
|
||||
|
||||
### CI deploy (Gitea Actions)
|
||||
|
||||
`.gitea/workflows/deploy-site.yml` runs `wrangler deploy` on every push to `main`/`master` that
|
||||
touches `web/`. It stays inert until you add two repo secrets (Settings → Actions → Secrets):
|
||||
`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` (same values as above).
|
||||
|
||||
Cloudflare's raw REST API (`PUT /accounts/:id/workers/scripts/...`) exists, but the assets
|
||||
upload needs a manifest/session dance that wrangler already implements — use wrangler even in
|
||||
automation.
|
||||
|
||||
## Config knobs (`wrangler.jsonc` → `vars`)
|
||||
|
||||
- `GITEA_REPO_API` — Gitea repo API base; releases must be publicly readable.
|
||||
- `DOWNLOAD_URL` — manual `/apk` fallback while Gitea is unreachable.
|
||||
- `FDROID_URL` — set when the F-Droid listing exists; until then `/fdroid` loops home.
|
||||
- `SOURCE_URL` — public source mirror for the footer + `/source`.
|
||||
|
||||
Vars are plain (non-secret) config; change + `wrangler deploy` to apply.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
|
||||
<defs>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0E2433"/>
|
||||
<stop offset="1" stop-color="#071522"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="96" height="96" rx="21" fill="url(#tile)"/>
|
||||
<!-- the network, at rest -->
|
||||
<g fill="#1E4A5C">
|
||||
<circle cx="24" cy="24" r="2.6"/><circle cx="48" cy="24" r="2.6"/><circle cx="72" cy="24" r="2.6"/>
|
||||
<circle cx="24" cy="48" r="2.6"/> <circle cx="72" cy="48" r="2.6"/>
|
||||
<circle cx="24" cy="72" r="2.6"/><circle cx="48" cy="72" r="2.6"/><circle cx="72" cy="72" r="2.6"/>
|
||||
</g>
|
||||
<!-- one node, under examination -->
|
||||
<circle cx="48" cy="48" r="8" fill="none" stroke="#FFB454" stroke-opacity="0.3" stroke-width="2"/>
|
||||
<circle cx="48" cy="48" r="4.5" fill="#FFB454"/>
|
||||
<g fill="none" stroke="#35E0C4" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M 33 41 V 33 H 41"/>
|
||||
<path d="M 55 33 H 63 V 41"/>
|
||||
<path d="M 63 55 V 63 H 55"/>
|
||||
<path d="M 41 63 H 33 V 55"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -92 433 104">
|
||||
<!-- "echolot" — hand-drawn monoline letterforms (no font dependency). For dark grounds. -->
|
||||
<g fill="none" stroke="#E8F4F2" stroke-width="11" stroke-linecap="round">
|
||||
<path d="M 0 -24 H 48"/>
|
||||
<path d="M 48 -24 A 24 24 0 1 0 40.97 -7.03"/>
|
||||
<path d="M 110.97 -40.97 A 24 24 0 1 0 110.97 -7.03"/>
|
||||
<path d="M 140 -76 V 0"/>
|
||||
<path d="M 140 -24 A 24 24 0 0 1 188 -24 L 188 0"/>
|
||||
<circle cx="234" cy="-24" r="24"/>
|
||||
<path d="M 280 -76 V 0"/>
|
||||
<circle cx="326" cy="-24" r="24" stroke="#35E0C4"/>
|
||||
<path d="M 372 -48 H 402"/>
|
||||
<path d="M 387 -68 V 0"/>
|
||||
</g>
|
||||
<!-- the finding -->
|
||||
<circle cx="326" cy="-24" r="6.5" fill="#FFB454"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 742 B |
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -92 433 104">
|
||||
<!-- "echolot" — hand-drawn monoline letterforms (no font dependency). For light grounds. -->
|
||||
<g fill="none" stroke="#1B3540" stroke-width="11" stroke-linecap="round">
|
||||
<path d="M 0 -24 H 48"/>
|
||||
<path d="M 48 -24 A 24 24 0 1 0 40.97 -7.03"/>
|
||||
<path d="M 110.97 -40.97 A 24 24 0 1 0 110.97 -7.03"/>
|
||||
<path d="M 140 -76 V 0"/>
|
||||
<path d="M 140 -24 A 24 24 0 0 1 188 -24 L 188 0"/>
|
||||
<circle cx="234" cy="-24" r="24"/>
|
||||
<path d="M 280 -76 V 0"/>
|
||||
<circle cx="326" cy="-24" r="24" stroke="#0E9384"/>
|
||||
<path d="M 372 -48 H 402"/>
|
||||
<path d="M 387 -68 V 0"/>
|
||||
</g>
|
||||
<!-- the finding -->
|
||||
<circle cx="326" cy="-24" r="6.5" fill="#E08A1E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 743 B |
+156
-87
@@ -5,103 +5,124 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Echolot — depth soundings for your local network</title>
|
||||
<title>Echolot — measure, don't guess</title>
|
||||
<meta name="description" content="Free Android app for detecting and debugging local network issues: rogue DHCP, broken IPv6 RAs, MTU black holes, multicast loss, lying DNS. No root required.">
|
||||
<meta property="og:title" content="Echolot">
|
||||
<meta property="og:description" content="Depth soundings for your local network. F/OSS Android network diagnostics — no root required.">
|
||||
<meta property="og:description" content="Measure, don't guess. F/OSS Android network diagnostics — no root required.">
|
||||
<meta property="og:url" content="https://echo-lot.app/">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23071A29'/%3E%3Cg fill='none' stroke='%23FFB454' stroke-width='2'%3E%3Ccircle cx='16' cy='16' r='3' fill='%23FFB454' stroke='none'/%3E%3Cpath d='M16 6a10 10 0 0 1 10 10'/%3E%3Cpath d='M16 1a15 15 0 0 1 15 15' opacity='.5'/%3E%3C/g%3E%3C/svg%3E">
|
||||
<meta property="og:image" content="https://echo-lot.app/assets/social-preview.png">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#071522">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#F2F6F7">
|
||||
<link rel="icon" href="/assets/icon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/assets/icon.png" type="image/png" sizes="512x512">
|
||||
<link rel="apple-touch-icon" href="/assets/icon.png">
|
||||
<style>
|
||||
/* Branding: teal is always the instrument (links, brackets, controls);
|
||||
the single amber point is the finding (the focused node, the version
|
||||
readout). Dark = the instrument's own display; light = the same tokens
|
||||
on paper. Palette from assets/branding/. */
|
||||
:root {
|
||||
--depth-0: #0B2437; /* surface */
|
||||
--depth-1: #092031; /* photic */
|
||||
--depth-2: #071A29; /* mid */
|
||||
--depth-3: #051320; /* floor */
|
||||
--foam: #DCE9F1; /* primary text */
|
||||
--slate: #8AA5B8; /* secondary text */
|
||||
--grid: #16374E; /* hairlines, chart grid */
|
||||
--ping: #FFB454; /* the one accent: sonar amber */
|
||||
--ok: #7BC98F; /* verdict green, chips only */
|
||||
color-scheme: dark;
|
||||
--bg-0: #0C2130; /* top of page */
|
||||
--bg-1: #071522; /* abyss — page floor, panel ground */
|
||||
--tile: #0E2433; /* raised surfaces */
|
||||
--foam: #E8F4F2; /* primary text */
|
||||
--slate: #7DA2AC; /* secondary text */
|
||||
--caption:#5E8B96; /* mono captions, from the banner */
|
||||
--grid: #10303F; /* hairlines */
|
||||
--rest: #1E4A5C; /* the network, at rest */
|
||||
--teal: #35E0C4; /* instrument */
|
||||
--on-teal:#04212B; /* text on teal */
|
||||
--amber: #FFB454; /* the finding */
|
||||
--mono: "Cascadia Code", "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
--sans: "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg-0: #F2F6F7;
|
||||
--bg-1: #E4ECEF;
|
||||
--tile: #EBF1F3;
|
||||
--foam: #1B3540; /* ink, from wordmark-on-light */
|
||||
--slate: #47656F;
|
||||
--caption:#5E8B96;
|
||||
--grid: #C4D3D8;
|
||||
--rest: #9FB8C1;
|
||||
--teal: #0E9384; /* instrument, printable contrast */
|
||||
--on-teal:#F5FBFA;
|
||||
--amber: #C77413; /* finding ink */
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
color: var(--foam);
|
||||
background: linear-gradient(var(--depth-0), var(--depth-1) 30%, var(--depth-2) 65%, var(--depth-3));
|
||||
background: linear-gradient(var(--bg-0), var(--bg-1) 70%);
|
||||
min-height: 100vh;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--ping); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
||||
a { color: var(--teal); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
||||
a:hover { text-decoration-thickness: 2px; }
|
||||
:focus-visible { outline: 2px solid var(--ping); outline-offset: 3px; border-radius: 2px; }
|
||||
:focus-visible { outline: 2px solid var(--teal); outline-offset: 3px; border-radius: 2px; }
|
||||
|
||||
.col { max-width: 46rem; margin: 0 auto; padding: 0 1.25rem; }
|
||||
|
||||
/* Depth ruler: fixed left margin scale, desktop only. Marks are set per-section
|
||||
by scroll position purely decoratively — it is a ruler, not navigation. */
|
||||
.ruler {
|
||||
/* Graduated rule: the tick motif from the banner, vertical. Fixed left
|
||||
margin, desktop only, purely decorative. */
|
||||
.rule {
|
||||
position: fixed; top: 0; bottom: 0; left: 0; width: 3.5rem;
|
||||
border-right: 1px solid var(--grid);
|
||||
font-family: var(--mono); font-size: .65rem; color: var(--slate);
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 72rem) { .ruler { display: block; } }
|
||||
.ruler span {
|
||||
position: absolute; right: .5rem; transform: translateY(-50%);
|
||||
}
|
||||
.ruler span::after {
|
||||
content: ""; position: absolute; right: -.55rem; top: 50%;
|
||||
width: .35rem; height: 1px; background: var(--slate);
|
||||
@media (min-width: 72rem) { .rule { display: block; } }
|
||||
.rule::after {
|
||||
content: ""; position: absolute; right: 0; top: 0; bottom: 0; width: .4rem;
|
||||
background: repeating-linear-gradient(to bottom, var(--rest) 0 1.5px, transparent 1.5px 60px);
|
||||
}
|
||||
|
||||
header.hero { padding: 4.5rem 0 3rem; }
|
||||
.wordmark {
|
||||
font-family: var(--mono); font-size: .8rem; letter-spacing: .35em;
|
||||
text-transform: uppercase; color: var(--slate);
|
||||
}
|
||||
.wordmark b { color: var(--ping); font-weight: 600; }
|
||||
.wordmark { display: block; height: 30px; width: auto; }
|
||||
h1 {
|
||||
font-size: clamp(1.9rem, 5vw, 3rem);
|
||||
font-weight: 650; letter-spacing: -.02em; line-height: 1.15;
|
||||
margin: 1rem 0 .75rem; max-width: 30ch;
|
||||
margin: 1.75rem 0 .75rem; max-width: 30ch;
|
||||
}
|
||||
.hero p.lede { color: var(--slate); max-width: 52ch; font-size: 1.05rem; }
|
||||
.hero p.lede strong { color: var(--foam); font-weight: 600; }
|
||||
|
||||
/* Echogram: the signature. A chart-recorder trace of ping RTTs; the sweep
|
||||
line is the sounder, the profile is the "seabed" the echoes draw. */
|
||||
figure.echogram {
|
||||
/* Focus panel: the signature, straight from the mark. The network at rest,
|
||||
one node under examination — teal brackets are the instrument, the amber
|
||||
point is the finding. */
|
||||
figure.focus {
|
||||
margin: 2.5rem 0 0; border: 1px solid var(--grid); border-radius: 4px;
|
||||
background:
|
||||
repeating-linear-gradient(to right, transparent 0 39px, var(--grid) 39px 40px),
|
||||
repeating-linear-gradient(to bottom, transparent 0 31px, var(--grid) 31px 32px),
|
||||
var(--depth-3);
|
||||
background: var(--tile);
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.echogram svg { display: block; width: 100%; height: auto; }
|
||||
.echogram figcaption {
|
||||
.focus svg { display: block; width: 100%; height: auto; }
|
||||
.focus .rest-node { fill: var(--rest); }
|
||||
.focus .bracket { fill: none; stroke: var(--teal); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.focus .finding { fill: var(--amber); }
|
||||
.focus .halo { fill: none; stroke: var(--amber); stroke-width: 2; opacity: .3; }
|
||||
.focus .readout { font-family: var(--mono); font-size: 11px; fill: var(--slate); }
|
||||
.focus .readout .flag { fill: var(--amber); }
|
||||
.focus .lead { stroke: var(--grid); stroke-width: 1; }
|
||||
.focus figcaption {
|
||||
position: absolute; top: .5rem; left: .75rem;
|
||||
font-family: var(--mono); font-size: .65rem; color: var(--slate);
|
||||
font-family: var(--mono); font-size: .65rem; color: var(--caption);
|
||||
}
|
||||
.sweep {
|
||||
position: absolute; top: 0; bottom: 0; width: 1px;
|
||||
background: var(--ping); opacity: .8;
|
||||
box-shadow: 0 0 8px var(--ping);
|
||||
animation: sweep 7s linear infinite;
|
||||
}
|
||||
@keyframes sweep { from { left: 0; } to { left: 100%; } }
|
||||
@keyframes examine { 0%, 100% { opacity: .3; } 50% { opacity: .1; } }
|
||||
.focus .halo { animation: examine 3.2s ease-in-out infinite; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sweep { animation: none; left: 62%; }
|
||||
.focus .halo { animation: none; }
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
section { padding: 3.5rem 0 0; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono); font-size: .7rem; letter-spacing: .25em;
|
||||
text-transform: uppercase; color: var(--ping);
|
||||
text-transform: uppercase; color: var(--teal);
|
||||
}
|
||||
h2 { font-size: 1.35rem; font-weight: 650; margin: .5rem 0 1rem; letter-spacing: -.01em; }
|
||||
section > .col > p { color: var(--slate); max-width: 58ch; }
|
||||
@@ -129,21 +150,26 @@
|
||||
border: 1px solid var(--grid); border-radius: 3px; padding: .35rem .6rem;
|
||||
color: var(--slate);
|
||||
}
|
||||
.tier b { color: var(--ok); font-weight: 600; }
|
||||
.tier b { color: var(--teal); font-weight: 600; }
|
||||
|
||||
/* Install */
|
||||
.buttons { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1.5rem 0 1rem; }
|
||||
.release {
|
||||
font-family: var(--mono); font-size: .8rem; color: var(--slate);
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.release b { color: var(--amber); font-weight: 600; }
|
||||
.buttons { display: flex; gap: .75rem; flex-wrap: wrap; margin: 1rem 0 1rem; }
|
||||
.btn {
|
||||
display: inline-block; padding: .7rem 1.3rem; border-radius: 4px;
|
||||
font-weight: 600; text-decoration: none; font-size: .95rem;
|
||||
}
|
||||
.btn.primary { background: var(--ping); color: var(--depth-3); }
|
||||
.btn.primary { background: var(--teal); color: var(--on-teal); }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn.ghost { border: 1px solid var(--grid); color: var(--foam); }
|
||||
.btn.ghost:hover { border-color: var(--slate); }
|
||||
.note {
|
||||
font-size: .85rem; color: var(--slate);
|
||||
border-left: 2px solid var(--ping); padding-left: .9rem; max-width: 52ch;
|
||||
border-left: 2px solid var(--teal); padding-left: .9rem; max-width: 52ch;
|
||||
}
|
||||
.checksum { font-family: var(--mono); font-size: .75rem; color: var(--slate); margin-top: 1rem; }
|
||||
|
||||
@@ -157,45 +183,51 @@
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="ruler" aria-hidden="true">
|
||||
<span style="top:6%">0 m</span>
|
||||
<span style="top:28%">─ 20</span>
|
||||
<span style="top:50%">─ 40</span>
|
||||
<span style="top:72%">─ 60</span>
|
||||
<span style="top:94%">─ 80</span>
|
||||
</div>
|
||||
<div class="rule" aria-hidden="true"></div>
|
||||
|
||||
<header class="hero">
|
||||
<div class="col">
|
||||
<p class="wordmark"><b>●</b> echo·lot <span aria-hidden="true">/ˈɛçolo:t/ — echo sounder</span></p>
|
||||
<h1>Depth soundings for your local network.</h1>
|
||||
<p class="lede">An echo sounder maps the seabed by timing returns. <strong>Echolot</strong> does the
|
||||
same to your network: free Android diagnostics for the layer where things actually break —
|
||||
<picture>
|
||||
<source srcset="/assets/wordmark-on-dark.svg" media="(prefers-color-scheme: dark)">
|
||||
<img class="wordmark" src="/assets/wordmark-on-light.svg" alt="echolot" width="125" height="30">
|
||||
</picture>
|
||||
<h1>Measure, don't guess.</h1>
|
||||
<p class="lede">Free Android diagnostics for the layer where networks actually break.
|
||||
<strong>Echolot</strong> takes the failure you can feel and pins it to a fact you can show —
|
||||
<strong>no root required</strong>. Built for people who know what a neighbor table is.</p>
|
||||
|
||||
<figure class="echogram">
|
||||
<figcaption>trace · icmp.ping4 · rtt ms ↓ / t →</figcaption>
|
||||
<svg viewBox="0 0 720 190" role="img" aria-label="Chart-recorder style trace of ping round-trip times, drawn like a sonar seabed profile">
|
||||
<!-- echo returns: the profile -->
|
||||
<polyline fill="none" stroke="#FFB454" stroke-width="1.5" opacity=".9"
|
||||
points="0,138 40,136 80,139 120,135 160,137 200,141 240,138 260,120 280,96 300,88 320,94 340,118 360,134 400,136 440,133 480,158 500,171 520,168 540,150 560,139 600,137 640,140 680,136 720,138"/>
|
||||
<!-- second, fainter return (multipath) -->
|
||||
<polyline fill="none" stroke="#FFB454" stroke-width="1" opacity=".25"
|
||||
points="0,148 40,146 80,149 120,145 160,147 200,151 240,148 260,132 280,110 300,101 320,107 340,129 360,144 400,146 440,143 480,168 500,180 520,177 540,160 560,149 600,147 640,150 680,146 720,148"/>
|
||||
<!-- dropped probes -->
|
||||
<g fill="#8AA5B8" font-family="monospace" font-size="9">
|
||||
<text x="497" y="30">×</text><text x="507" y="30">×</text>
|
||||
<text x="288" y="30">▲ spike: wifi→cell handover</text>
|
||||
<figure class="focus">
|
||||
<figcaption>focus · dhcp.rogue_detect · tier:app</figcaption>
|
||||
<svg viewBox="0 0 720 240" role="img" aria-label="A grid of network nodes at rest; one node is framed by viewfinder brackets, highlighted as a finding: two DHCP servers answered the same DISCOVER">
|
||||
<!-- the network, at rest -->
|
||||
<g class="rest-node">
|
||||
<circle cx="120" cy="60" r="3"/><circle cx="240" cy="60" r="3"/><circle cx="360" cy="60" r="3"/><circle cx="480" cy="60" r="3"/><circle cx="600" cy="60" r="3"/>
|
||||
<circle cx="120" cy="120" r="3"/><circle cx="480" cy="120" r="3"/><circle cx="600" cy="120" r="3"/>
|
||||
<circle cx="120" cy="180" r="3"/><circle cx="240" cy="180" r="3"/><circle cx="360" cy="180" r="3"/><circle cx="480" cy="180" r="3"/><circle cx="600" cy="180" r="3"/>
|
||||
</g>
|
||||
<!-- one node, under examination -->
|
||||
<circle class="halo" cx="240" cy="120" r="11"/>
|
||||
<circle class="finding" cx="240" cy="120" r="5"/>
|
||||
<g class="bracket">
|
||||
<path d="M 222 111 V 102 H 231"/>
|
||||
<path d="M 249 102 H 258 V 111"/>
|
||||
<path d="M 258 129 V 138 H 249"/>
|
||||
<path d="M 231 138 H 222 V 129"/>
|
||||
</g>
|
||||
<!-- the readout -->
|
||||
<line class="lead" x1="262" y1="120" x2="296" y2="120"/>
|
||||
<g class="readout">
|
||||
<text x="304" y="112">DISCOVER → 2 OFFERs</text>
|
||||
<text x="304" y="130">192.168.1.1 gw · <tspan class="flag">192.168.1.223 — who is this?</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="sweep" aria-hidden="true"></div>
|
||||
</figure>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="what">
|
||||
<div class="col">
|
||||
<p class="eyebrow">What it sounds out</p>
|
||||
<p class="eyebrow">What it measures</p>
|
||||
<h2>Signal bars lie. Timings don't.</h2>
|
||||
<p>Most wifi apps show you signal strength and call it a diagnosis. The failures that ruin
|
||||
home and office networks live deeper: a second DHCP server nobody admits to, IPv6 router
|
||||
@@ -234,15 +266,16 @@
|
||||
<div class="col">
|
||||
<p class="eyebrow">Install</p>
|
||||
<h2>Get Echolot</h2>
|
||||
<p class="release" id="release" hidden></p>
|
||||
<div class="buttons">
|
||||
<a class="btn primary" href="/apk">Download APK</a>
|
||||
<a class="btn primary" id="dl-btn" href="/apk">Download APK</a>
|
||||
<a class="btn ghost" href="/fdroid">F-Droid</a>
|
||||
</div>
|
||||
<p class="note"><strong>Pre-release.</strong> The capability prober is running on real
|
||||
hardware; the production app is under construction. These links go live with the first
|
||||
release — until then they loop back here. No mailing list, no tracker: check back, or watch
|
||||
the <a href="/source">repository</a>.</p>
|
||||
<p class="checksum">releases will ship with sha256sums + a signing key you can pin</p>
|
||||
<p class="note" id="prerelease-note"><strong>Pre-release.</strong> The capability prober is
|
||||
running on real hardware; the production app is under construction. These links go live with
|
||||
the first release — until then they loop back here. No mailing list, no tracker: check back,
|
||||
or watch the <a href="/source">repository</a>.</p>
|
||||
<p class="checksum" id="checksum">releases will ship with sha256sums + a signing key you can pin</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -253,5 +286,41 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Release readout: asks this site's own Worker (/api/latest, which proxies the
|
||||
// Gitea "latest release" API, edge-cached). Progressive enhancement — with no
|
||||
// JS, no network, or no release yet, the static pre-release copy above stands.
|
||||
(async () => {
|
||||
let rel;
|
||||
try {
|
||||
const res = await fetch("/api/latest");
|
||||
if (!res.ok) return;
|
||||
rel = await res.json();
|
||||
} catch { return; }
|
||||
if (!rel || !rel.available) return;
|
||||
|
||||
const line = document.getElementById("release");
|
||||
const ver = document.createElement("b");
|
||||
ver.textContent = rel.version;
|
||||
line.append("» latest ", ver);
|
||||
const date = (rel.published_at || "").slice(0, 10);
|
||||
if (date) line.append(" · " + date);
|
||||
if (rel.apk && rel.apk.size) {
|
||||
line.append(" · " + (rel.apk.size / 1048576).toFixed(1) + " MiB");
|
||||
}
|
||||
line.hidden = false;
|
||||
|
||||
document.getElementById("prerelease-note").hidden = true;
|
||||
if (rel.sha256) {
|
||||
const c = document.getElementById("checksum");
|
||||
c.textContent = "";
|
||||
const a = document.createElement("a");
|
||||
a.href = "/apk.sha256";
|
||||
a.textContent = "sha256";
|
||||
c.append(a, " · verify before you sideload");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+47
-14
@@ -3,28 +3,32 @@
|
||||
|
||||
// Everything under public/ is served straight from the edge without invoking
|
||||
// this Worker. The Worker exists for the short stable URLs (/apk, /fdroid,
|
||||
// /source) — short enough for a QR code — and to resolve "/apk" to the newest
|
||||
// release asset at request time, so tagging a release in Gitea is the only
|
||||
// publish step. No site redeploy, no URL to update.
|
||||
// /source) — short enough for a QR code — and for /api/latest, which the
|
||||
// homepage uses to show the current version. Both resolve the newest release
|
||||
// from the Gitea API at request time, so tagging a release in Gitea is the
|
||||
// only publish step. No site redeploy, no URL to update.
|
||||
|
||||
const STATIC_ROUTES = {
|
||||
"/fdroid": "FDROID_URL",
|
||||
"/source": "SOURCE_URL",
|
||||
};
|
||||
|
||||
// Resolve the newest APK from the Gitea "latest release" API. Cached at the
|
||||
// edge for 5 minutes so a release becomes visible quickly, while Gitea sees
|
||||
// at most one API hit per POP per 5 min regardless of download traffic.
|
||||
async function latestApkUrl(env) {
|
||||
// Fetch the Gitea "latest release" object. Cached at the edge for 5 minutes so
|
||||
// a new release becomes visible quickly, while Gitea sees at most one API hit
|
||||
// per POP per 5 min regardless of traffic. Returns null on any failure —
|
||||
// callers degrade to fallbacks rather than surfacing errors.
|
||||
async function latestRelease(env) {
|
||||
if (!env.GITEA_REPO_API) return null;
|
||||
const res = await fetch(`${env.GITEA_REPO_API}/releases/latest`, {
|
||||
headers: { Accept: "application/json", "User-Agent": "echolot-site" },
|
||||
cf: { cacheTtl: 300, cacheEverything: true },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const rel = await res.json();
|
||||
const apk = rel.assets?.find((a) => a.name?.endsWith(".apk"));
|
||||
return apk?.browser_download_url ?? null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function asset(rel, suffix) {
|
||||
return rel?.assets?.find((a) => a.name?.endsWith(suffix)) ?? null;
|
||||
}
|
||||
|
||||
function redirect(location) {
|
||||
@@ -38,21 +42,50 @@ function redirect(location) {
|
||||
});
|
||||
}
|
||||
|
||||
function json(body, maxAge) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": `public, max-age=${maxAge}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const { pathname } = new URL(request.url);
|
||||
const path = pathname.replace(/\/$/, "");
|
||||
const fallback = new URL("/#install", request.url).toString();
|
||||
|
||||
if (path === "/apk" || path === "/download") {
|
||||
// Order: live Gitea release → manual override → install section.
|
||||
if (path === "/apk" || path === "/download" || path === "/apk.sha256") {
|
||||
const suffix = path === "/apk.sha256" ? ".sha256" : ".apk";
|
||||
let target = null;
|
||||
try {
|
||||
target = await latestApkUrl(env);
|
||||
target = asset(await latestRelease(env), suffix)?.browser_download_url;
|
||||
} catch {
|
||||
// Gitea unreachable — fall through rather than 500 on a download link.
|
||||
}
|
||||
return redirect(target || env.DOWNLOAD_URL || fallback);
|
||||
const override = suffix === ".apk" ? env.DOWNLOAD_URL : null;
|
||||
return redirect(target || override || fallback);
|
||||
}
|
||||
|
||||
if (path === "/api/latest") {
|
||||
let rel = null;
|
||||
try {
|
||||
rel = await latestRelease(env);
|
||||
} catch {}
|
||||
const apk = asset(rel, ".apk");
|
||||
if (!rel || !apk) return json({ available: false }, 60);
|
||||
return json(
|
||||
{
|
||||
available: true,
|
||||
version: rel.tag_name,
|
||||
published_at: rel.published_at,
|
||||
apk: { name: apk.name, size: apk.size, url: apk.browser_download_url },
|
||||
sha256: Boolean(asset(rel, ".sha256")),
|
||||
},
|
||||
300,
|
||||
);
|
||||
}
|
||||
|
||||
const varName = STATIC_ROUTES[path];
|
||||
|
||||
+5
-5
@@ -22,14 +22,14 @@
|
||||
// its route fall back to the homepage's install section, so nothing 404s
|
||||
// before the first release exists.
|
||||
"vars": {
|
||||
// Gitea repo API base, e.g. "https://git.example.net/api/v1/repos/mram/echolot".
|
||||
// The repo (or at least its releases) must be publicly readable.
|
||||
"GITEA_REPO_API": "",
|
||||
// Manual override / fallback while GITEA_REPO_API is unset or unreachable.
|
||||
// Gitea repo API base. The repo (or at least its releases) must be
|
||||
// publicly readable for /apk and /api/latest to resolve.
|
||||
"GITEA_REPO_API": "https://git.rambossek.at/api/v1/repos/EchoLot/echolot",
|
||||
// Manual override / fallback while GITEA_REPO_API is unreachable.
|
||||
"DOWNLOAD_URL": "",
|
||||
// F-Droid listing, once it exists: https://f-droid.org/packages/app.echo_lot.app/
|
||||
"FDROID_URL": "",
|
||||
// Public source URL, the footer + /source target.
|
||||
"SOURCE_URL": ""
|
||||
"SOURCE_URL": "https://git.rambossek.at/EchoLot/echolot"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user