Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 51 additions & 21 deletions public/js/pihole.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
})();
25 changes: 25 additions & 0 deletions tests/pihole_pause_countdown_ui.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading