diff --git a/.changeset/fleet-statistics-with-app.md b/.changeset/fleet-statistics-with-app.md
new file mode 100644
index 00000000..ea7863e9
--- /dev/null
+++ b/.changeset/fleet-statistics-with-app.md
@@ -0,0 +1,5 @@
+---
+"ftw": patch
+---
+
+Fleet statistics now live with the FTW app settings. Fleet sharing stays on by default for existing boxes and remains a separate switch, so it can stay on when the app relay is off. The panel still shows the exact daily payload and its privacy limits.
diff --git a/config.example.yaml b/config.example.yaml
index 9cee9ebd..df7d7a70 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -185,7 +185,7 @@ app_link:
# limits: those six fields still describe a household, so a rare mix may be
# the only one of its kind, and the relay sees the address during the request.
# It saves only daily totals, so the result counts reports, not unique boxes.
-# Settings → Fleet ping shows the exact message this box would send. On by
+# Settings → FTW app → Fleet statistics shows the exact message this box would send. On by
# default; `enabled: false` turns it off without a restart.
fleet_ping:
enabled: true
diff --git a/docs/architecture.md b/docs/architecture.md
index fba7b33a..70dfdcf0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -444,7 +444,7 @@ also sees the source IP while the request is open, though neither the relay nor
Caddy writes it. And with no id there is no way to dedupe, so the totals count
reports, not unique boxes.
-A failed send is forgotten, never retried. Settings → Fleet ping renders the
+A failed send is forgotten, never retried. Settings → FTW app → Fleet statistics renders the
exact payload from the same call the sender uses, so the claim is checkable
rather than promised. It is on by default; saving `enabled: false` opts out
without a restart. See [`go/internal/fleetping`](../go/internal/fleetping).
diff --git a/web/app-link-tab.test.mjs b/web/app-link-tab.test.mjs
index f5550397..54316a61 100644
--- a/web/app-link-tab.test.mjs
+++ b/web/app-link-tab.test.mjs
@@ -12,8 +12,10 @@ import vm from "node:vm";
// drive the real render() and read the markup it produces.
const source = readFileSync(new URL("./settings/tabs/app.js", import.meta.url), "utf8");
+const fleetSource = readFileSync(new URL("./settings/tabs/fleet.js", import.meta.url), "utf8");
+const index = readFileSync(new URL("./index.html", import.meta.url), "utf8");
-function loadTab() {
+function loadTab(withFleet = false) {
const win = { FTWSettings: { tabs: {} } };
const sandbox = {
window: win,
@@ -27,6 +29,7 @@ function loadTab() {
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(source, sandbox);
+ if (withFleet) vm.runInContext(fleetSource, sandbox);
const tab = win.FTWSettings.tabs.app;
assert.ok(tab && typeof tab.render === "function", "app.js registered no tab");
return tab;
@@ -80,4 +83,40 @@ describe("the app tab", () => {
// Starting enabled means the first press of a fresh page fails.
assert.match(render({}), /id="app-link-pair"[^>]*disabled/);
});
+
+ it("keeps fleet statistics with the app, but independent of its switch", () => {
+ const appWithFleet = loadTab(true);
+ const config = { app_link: { enabled: false } };
+ const html = appWithFleet.render({ config });
+
+ assert.match(html, /
" +
'' +
- ""
+ "" +
+ fleetPanel(ctx)
);
},
};
diff --git a/web/settings/tabs/fleet.js b/web/settings/tabs/fleet.js
index e8891b20..ac486180 100644
--- a/web/settings/tabs/fleet.js
+++ b/web/settings/tabs/fleet.js
@@ -159,7 +159,11 @@
});
}
- S.tabs.fleet = {
+ // The FTW app and fleet statistics share a settings page, but not a
+ // setting: a box can share its anonymous daily report without using the
+ // app relay. Keep this as a reusable panel instead of a tab so the payload
+ // and privacy wording stay in one place.
+ S.fleetPing = {
_pure: { payloadRows: payloadRows, describe: describe, whereLine: whereLine },
// The line under the payload is the box's answer, not the checkbox's, so