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/.
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.skipWaiting();

View File

@ -145,19 +145,34 @@
{% endif %}
</div>
<div class="osiris-card" id="reminders" hidden>
<div class="osiris-card" id="reminders">
<h2 style="margin-top:0">Reminders</h2>
<p class="dose-detail" id="reminder-status">Checking…</p>
<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>
Send test
</button>
</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.
Set those times on the medication in the admin.
</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>
<p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</a></p>
@ -165,19 +180,80 @@
<script>
(function () {
const VAPID_PUBLIC_KEY = "{{ vapid_public_key|escapejs }}";
const card = document.getElementById("reminders");
const status = document.getElementById("reminder-status");
const toggle = document.getElementById("reminder-toggle");
const test = document.getElementById("reminder-test");
// 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 checks = document.getElementById("reminder-checks");
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) {
return fetch(url, {
method: "POST",
@ -202,16 +278,37 @@
}
let subscription = null;
let worker = null;
function render() {
const blocked = Notification.permission === "denied";
if (blocked) {
renderChecks([
{
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 =
"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;
test.hidden = true;
return;
}
toggle.hidden = false;
toggle.disabled = false;
toggle.textContent = subscription ? "Turn reminders off" : "Turn reminders on";
@ -222,10 +319,18 @@
}
navigator.serviceWorker.ready
.then((registration) => registration.pushManager.getSubscription())
.then((registration) => {
worker = registration;
return registration.pushManager.getSubscription();
})
.then((existing) => {
subscription = existing;
render();
})
.catch((error) => {
status.textContent = "Service worker failed: " + error.message;
renderChecks();
document.querySelector(".osiris-debug").open = true;
});
toggle.addEventListener("click", async () => {

View File

@ -259,6 +259,34 @@ class PwaTests(TrackerTestCase):
self.assertContains(response, 'rel="manifest"')
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):
response = self.client.get(reverse("manifest"))

View File

@ -104,7 +104,14 @@ def today(request):
"next_date": day + timedelta(days=1),
"chart": build_chart(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,
"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(),
},
)