fix: add vapid diagnostics

This commit is contained in:
Loïc Gremaud 2026-07-14 21:25:26 +02:00
parent 5198826f83
commit f783794b86
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
4 changed files with 158 additions and 16 deletions

View File

@ -1,5 +1,7 @@
// Served from / (not /static/) so its scope covers /admin/today/. // Served from / (not /static/) so its scope covers /admin/today/.
const CACHE = "osiris-v1"; // Bump on any change here or to the cached pages: activate() drops older caches,
// which is what stops a stale Today page surviving a deploy.
const CACHE = "osiris-v2";
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
self.skipWaiting(); self.skipWaiting();

View File

@ -145,19 +145,34 @@
{% endif %} {% endif %}
</div> </div>
<div class="osiris-card" id="reminders" hidden> <div class="osiris-card" id="reminders">
<h2 style="margin-top:0">Reminders</h2> <h2 style="margin-top:0">Reminders</h2>
<p class="dose-detail" id="reminder-status">Checking…</p> <p class="dose-detail" id="reminder-status">Checking…</p>
<div style="display:flex; gap:8px; flex-wrap:wrap"> <div style="display:flex; gap:8px; flex-wrap:wrap">
<button type="button" id="reminder-toggle" class="osiris-save" style="flex:1; min-width:140px"></button> <button type="button" id="reminder-toggle" class="osiris-save" style="flex:1; min-width:140px" hidden></button>
<button type="button" id="reminder-test" class="osiris-save" style="flex:1; min-width:140px; background:transparent; color:inherit; border:1px solid var(--border-color)" hidden> <button type="button" id="reminder-test" class="osiris-save" style="flex:1; min-width:140px; background:transparent; color:inherit; border:1px solid var(--border-color)" hidden>
Send test Send test
</button> </button>
</div> </div>
<p class="dose-detail" style="margin-bottom:0"> <p class="dose-detail">
A reminder is sent at each dose's reminder time, and only if it isn't already ticked off. A reminder is sent at each dose's reminder time, and only if it isn't already ticked off.
Set those times on the medication in the admin. Set those times on the medication in the admin.
</p> </p>
<details class="osiris-debug">
<summary class="dose-detail" style="cursor:pointer">Diagnostics</summary>
<ul id="reminder-checks"></ul>
<ul>
<li class="dose-detail">
{% if doses_with_reminders %}✅{% else %}❌{% endif %}
Doses with a reminder time: {{ doses_with_reminders }} of {{ day.doses|length }}
{% if not doses_with_reminders %}— set one on the medication in the admin, or nothing will ever fire{% endif %}
</li>
<li class="dose-detail">
{% if subscribed_devices %}✅{% else %}❌{% endif %}
Devices subscribed (this account): {{ subscribed_devices }}
</li>
</ul>
</details>
</div> </div>
<p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</a></p> <p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</a></p>
@ -165,19 +180,80 @@
<script> <script>
(function () { (function () {
const VAPID_PUBLIC_KEY = "{{ vapid_public_key|escapejs }}"; const VAPID_PUBLIC_KEY = "{{ vapid_public_key|escapejs }}";
const card = document.getElementById("reminders");
const status = document.getElementById("reminder-status"); const status = document.getElementById("reminder-status");
const toggle = document.getElementById("reminder-toggle"); const toggle = document.getElementById("reminder-toggle");
const test = document.getElementById("reminder-test"); const test = document.getElementById("reminder-test");
const checks = document.getElementById("reminder-checks");
// No keys configured, or a browser without push: leave the card hidden entirely.
if (!VAPID_PUBLIC_KEY || !("serviceWorker" in navigator) || !("PushManager" in window)) {
return;
}
card.hidden = false;
const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value; const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value;
// Every precondition for Web Push, each one reported by name. Push fails silently
// in a dozen ways; the point here is to say which one, rather than hide the card.
function preconditions() {
return [
{
name: "Server VAPID keys",
ok: Boolean(VAPID_PUBLIC_KEY),
detail: VAPID_PUBLIC_KEY
? VAPID_PUBLIC_KEY.slice(0, 12) + "…"
: "not set — run: manage.py generate_vapid_keys, then set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in the server environment and restart",
},
{
name: "Secure context (HTTPS)",
ok: window.isSecureContext,
detail: window.isSecureContext
? location.origin
: location.origin + " — push needs https:// (or localhost)",
},
{
name: "Service worker support",
ok: "serviceWorker" in navigator,
detail: "serviceWorker" in navigator ? "supported" : "not supported by this browser",
},
{
name: "Push support",
ok: "PushManager" in window,
detail:
"PushManager" in window
? "supported"
: "not supported — on Android use Chrome; Firefox in a private window will not do",
},
{
name: "Notification permission",
ok: typeof Notification !== "undefined" && Notification.permission !== "denied",
detail:
typeof Notification === "undefined"
? "Notification API missing"
: Notification.permission,
},
];
}
function renderChecks(extra) {
const rows = preconditions().concat(extra || []);
checks.innerHTML = "";
for (const row of rows) {
const li = document.createElement("li");
li.className = "dose-detail";
li.textContent = `${row.ok ? "✅" : "❌"} ${row.name}: ${row.detail}`;
checks.appendChild(li);
}
return rows.every((row) => row.ok);
}
const blocking = preconditions().filter((row) => !row.ok);
if (blocking.length) {
// Notification.permission === "denied" is recoverable, so it is not fatal here;
// anything else means the button would do nothing at all.
const fatal = blocking.filter((row) => row.name !== "Notification permission");
if (fatal.length) {
status.textContent = `Reminders unavailable — ${fatal[0].name}: ${fatal[0].detail}`;
renderChecks();
document.querySelector(".osiris-debug").open = true;
return;
}
}
function post(url, body) { function post(url, body) {
return fetch(url, { return fetch(url, {
method: "POST", method: "POST",
@ -202,16 +278,37 @@
} }
let subscription = null; let subscription = null;
let worker = null;
function render() { function render() {
const blocked = Notification.permission === "denied"; renderChecks([
if (blocked) { {
name: "Service worker",
ok: Boolean(worker),
detail: worker ? "active at " + worker.scope : "not registered yet — reload the page",
},
{
name: "This device subscribed",
ok: Boolean(subscription),
detail: subscription ? subscription.endpoint.slice(0, 48) + "…" : "no",
},
{
name: "Installed as an app",
ok: window.matchMedia("(display-mode: standalone)").matches,
detail: window.matchMedia("(display-mode: standalone)").matches
? "yes"
: "no — works in the browser too, but Android delivers reliably once installed",
},
]);
if (Notification.permission === "denied") {
status.textContent = status.textContent =
"Notifications are blocked for this site. Re-allow them in Chrome's site settings."; "Notifications are blocked for this site. Re-allow them in Chrome → site settings → Notifications.";
toggle.hidden = true; toggle.hidden = true;
test.hidden = true; test.hidden = true;
return; return;
} }
toggle.hidden = false; toggle.hidden = false;
toggle.disabled = false; toggle.disabled = false;
toggle.textContent = subscription ? "Turn reminders off" : "Turn reminders on"; toggle.textContent = subscription ? "Turn reminders off" : "Turn reminders on";
@ -222,10 +319,18 @@
} }
navigator.serviceWorker.ready navigator.serviceWorker.ready
.then((registration) => registration.pushManager.getSubscription()) .then((registration) => {
worker = registration;
return registration.pushManager.getSubscription();
})
.then((existing) => { .then((existing) => {
subscription = existing; subscription = existing;
render(); render();
})
.catch((error) => {
status.textContent = "Service worker failed: " + error.message;
renderChecks();
document.querySelector(".osiris-debug").open = true;
}); });
toggle.addEventListener("click", async () => { toggle.addEventListener("click", async () => {

View File

@ -259,6 +259,34 @@ class PwaTests(TrackerTestCase):
self.assertContains(response, 'rel="manifest"') self.assertContains(response, 'rel="manifest"')
self.assertContains(response, "serviceWorker.register") self.assertContains(response, "serviceWorker.register")
@override_settings(VAPID_PUBLIC_KEY="", VAPID_PRIVATE_KEY="")
def test_the_reminder_card_explains_itself_when_keys_are_missing(self):
# It used to hide entirely, which is indistinguishable from a broken page.
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Reminders")
self.assertContains(response, "Diagnostics")
self.assertContains(response, "generate_vapid_keys")
@override_settings(VAPID_PUBLIC_KEY="BMn-test-public-key")
def test_the_diagnostics_report_reminder_times_and_devices(self):
self.client.force_login(self.user)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Doses with a reminder time: 0 of 3")
self.assertContains(response, "Devices subscribed (this account): 0")
self.morning.reminder_time = "08:00"
self.morning.save()
PushSubscription.objects.create(
user=self.user, endpoint="https://push.example/a", p256dh="k", auth="s"
)
response = self.client.get(reverse("tracker:today"))
self.assertContains(response, "Doses with a reminder time: 1 of 3")
self.assertContains(response, "Devices subscribed (this account): 1")
def test_the_manifest_is_valid_json_pointing_at_real_icons(self): def test_the_manifest_is_valid_json_pointing_at_real_icons(self):
response = self.client.get(reverse("manifest")) response = self.client.get(reverse("manifest"))

View File

@ -104,7 +104,14 @@ def today(request):
"next_date": day + timedelta(days=1), "next_date": day + timedelta(days=1),
"chart": build_chart(readings), "chart": build_chart(readings),
"reading_count": len(readings), "reading_count": len(readings),
# Empty when push is not configured; the page then hides the card. # Empty when push is not configured; the card then says so rather than
# vanishing, because a missing card looks like a bug in the page.
"vapid_public_key": settings.VAPID_PUBLIC_KEY, "vapid_public_key": settings.VAPID_PUBLIC_KEY,
"doses_with_reminders": ScheduledDose.objects.filter(
is_active=True,
medication__is_active=True,
reminder_time__isnull=False,
).count(),
"subscribed_devices": request.user.push_subscriptions.count(),
}, },
) )