diff --git a/CHANGELOG.md b/CHANGELOG.md index ae465f4f..9daf3b85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ## [Unreleased] ### Fixed +- Pi-hole page: the "Blockierung steuern" pause buttons now show a live countdown on the blocking badge (e.g. `Off · 0:29`) and update instantly — previously the click sent the request but nothing visible changed. The badge reflects the pause optimistically, ticks down once per second, and refreshes the real state when the timer expires. - Smart Home rules: rule-limit (409) now shows only the banner instead of banner + alert (double signal) - Smart Home rules: rule-name field enforces the 20-char cap client-side (`maxlength`) across all three themes - Smart Home rules: `limitWarn` now derives from `deconzCapabilities.ruleLimit.warnAtGcRules` (single source) instead of a duplicated magic threshold; corrected a stale comment on cancel-condition inversion diff --git a/public/js/pihole.js b/public/js/pihole.js index c349c680..13e1cd8a 100644 --- a/public/js/pihole.js +++ b/public/js/pihole.js @@ -57,6 +57,42 @@ function T(k, d) { return (window.GC && GC.t && GC.t[k]) || d; } + // ── Blocking status badge + pause countdown ────────────────── + // Shared by both theme renderers and the 1s ticker below. The server sends + // blocking = { state, timer } where timer is the seconds left on a pause; we + // turn that into a local deadline and count it down between summary syncs. + var blockingState = null; // 'enabled' | 'disabled' | 'partial' | null + var pauseEndsAt = null; // epoch ms a timed pause ends, else null + + function fmtCountdown(sec) { + var m = Math.floor(sec / 60), s = sec % 60; + return m + ':' + String(s).padStart(2, '0'); + } + + function paintBlockingBadge() { + var badgeEl = document.getElementById('ph-blocking-badge'); + if (!badgeEl) return; + var cls = 'tag-grey', txt = '—'; + if (blockingState === 'enabled') { cls = 'tag-green'; txt = T('pihole.blocking_on', 'On'); } + else if (blockingState === 'disabled') { + cls = 'tag-red'; txt = T('pihole.blocking_off', 'Off'); + if (pauseEndsAt) { + var rem = Math.ceil((pauseEndsAt - Date.now()) / 1000); + if (rem > 0) txt += ' · ' + fmtCountdown(rem); + } + } else if (blockingState === 'partial') { cls = 'tag-amber'; txt = T('pihole.blocking_partial', 'Partial'); } + replaceChildren(badgeEl, badge(cls, txt)); + } + + // Sync badge from a /summary blocking payload ({ state, timer }). + function updateBlocking(blocking) { + blocking = blocking || {}; + blockingState = blocking.state || null; + var timer = Number(blocking.timer); + pauseEndsAt = (blockingState === 'disabled' && timer > 0) ? Date.now() + timer * 1000 : null; + paintBlockingBadge(); + } + // ── Aurora: Summary (donut + pi-stats) ─────────────────────── function auroraRenderSummary(data) { const q = data.queries || {}; @@ -87,16 +123,7 @@ const clActive = cl && typeof cl === 'object' ? cl.active : cl; setText('ph-stat-clients', fmtNum(clActive)); - const blocking = data.blocking || {}; - const badgeEl = document.getElementById('ph-blocking-badge'); - if (badgeEl) { - const state = blocking.state; - let cls = 'tag-grey', txt = '—'; - if (state === 'enabled') { cls = 'tag-green'; txt = T('pihole.blocking_on', 'On'); } - else if (state === 'disabled') { cls = 'tag-red'; txt = T('pihole.blocking_off', 'Off'); } - else if (state === 'partial') { cls = 'tag-amber'; txt = T('pihole.blocking_partial', 'Partial'); } - replaceChildren(badgeEl, badge(cls, txt)); - } + updateBlocking(data.blocking || {}); const warn = document.getElementById('ph-attribution-warn'); if (warn) warn.style.display = data.attribution === 'collapsed' ? '' : 'none'; @@ -159,16 +186,7 @@ const clActive = cl && typeof cl === 'object' ? cl.active : cl; setText('ph-stat-clients', fmtNum(clActive)); - const blocking = data.blocking || {}; - const badgeEl = document.getElementById('ph-blocking-badge'); - if (badgeEl) { - const state = blocking.state; - let cls = 'tag-grey', txt = '—'; - if (state === 'enabled') { cls = 'tag-green'; txt = T('pihole.blocking_on', 'On'); } - else if (state === 'disabled') { cls = 'tag-red'; txt = T('pihole.blocking_off', 'Off'); } - else if (state === 'partial') { cls = 'tag-amber'; txt = T('pihole.blocking_partial', 'Partial'); } - replaceChildren(badgeEl, badge(cls, txt)); - } + updateBlocking(data.blocking || {}); const warn = document.getElementById('ph-attribution-warn'); if (warn) warn.style.display = data.attribution === 'collapsed' ? '' : 'none'; @@ -322,7 +340,11 @@ const body = { enabled: enabled }; if (timer) body.timer = timer; await api.post('/api/v1/pihole/blocking', body); - await load(); + // Optimistic: show the new state (and start the countdown) instantly. The + // server applies the change asynchronously and then pushes ground truth via + // the 'pihole' SSE event — an immediate load() here would race ahead of that + // resync and read back the stale pre-toggle state. + updateBlocking(enabled ? { state: 'enabled' } : { state: 'disabled', timer: timer }); } catch (err) { console.error('Pi-hole blocking change failed:', err.message); } @@ -371,5 +393,13 @@ load(); }); + // Tick the pause countdown once per second. When it reaches zero Pi-hole has + // re-enabled itself (its own timer), so pull the real state. + setInterval(function () { + if (!pauseEndsAt) return; + if (Date.now() >= pauseEndsAt) { pauseEndsAt = null; load(); return; } + paintBlockingBadge(); + }, 1000); + load(); })(); diff --git a/tests/pihole_pause_countdown_ui.test.js b/tests/pihole_pause_countdown_ui.test.js new file mode 100644 index 00000000..f83f062e --- /dev/null +++ b/tests/pihole_pause_countdown_ui.test.js @@ -0,0 +1,25 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'pihole.js'), 'utf8'); + +test('pihole.js wires the pause countdown (state + ticker)', () => { + assert.ok(/function updateBlocking\(/.test(src), 'updateBlocking helper'); + assert.ok(/pauseEndsAt/.test(src), 'pause deadline tracked'); + assert.ok(/setInterval\(/.test(src), 'per-second ticker'); + // The badge text gets the countdown appended when a pause is active. + assert.ok(/fmtCountdown\(rem\)/.test(src), 'countdown appended to Off badge'); + // Toggle is optimistic — no immediate load() racing the async server resync. + assert.ok(/updateBlocking\(enabled \?/.test(src), 'optimistic badge update on toggle'); +}); + +test('fmtCountdown uses zero-padded m:ss math', () => { + // pihole.js is a DOM-bound IIFE with no exports, so assert on the formula: + // minutes = floor(sec/60), seconds = sec%60 zero-padded to 2 digits. + assert.ok(/Math\.floor\(sec \/ 60\)/.test(src), 'minutes = floor(sec/60)'); + assert.ok(/sec % 60/.test(src), 'seconds = sec % 60'); + assert.ok(/padStart\(2, '0'\)/.test(src), 'seconds zero-padded to 2 digits'); +});